diff --git a/AndroidProjectTooling.sh b/AndroidProjectTooling.sh index 417c338..f9e33c2 100755 --- a/AndroidProjectTooling.sh +++ b/AndroidProjectTooling.sh @@ -18,6 +18,7 @@ LOCAL_PROPERTIES_PATH="$PROJECT_DIR/local.properties" HOST_GIT_BINARY="$PROJECT_DIR/build/host-git/libgit.so" GIT_SRC_DIR="$TMP_DIR/git-src" HOST_GIT_DIR="$PROJECT_DIR/build/host-git" +HOST_GIT_STAMP="$HOST_GIT_DIR/.source-fingerprint" ANDROID_JNI_DIR="$PROJECT_DIR/app/src/main/jniLibs" ANDROID_ASSET_MANPAGE_DIR="$PROJECT_DIR/app/src/main/assets/manpages" ANDROID_API=24 @@ -309,10 +310,46 @@ bundle_git_manpages() { done < <(find "$GIT_SRC_DIR/Documentation" -maxdepth 1 -type f \( -name 'git*.txt' -o -name 'git*.adoc' -o -name 'gitignore.txt' -o -name 'gitignore.adoc' \)) } +git_source_fingerprint() { + ensure_git_source + + local head + local tracked_changes + head="$(git -C "$GIT_SRC_DIR" rev-parse HEAD)" + tracked_changes="$(git -C "$GIT_SRC_DIR" diff --binary HEAD -- | git hash-object --stdin)" + printf '%s:%s\n' "$head" "$tracked_changes" +} + +target_is_current() { + local output="$1" + local stamp="$2" + local fingerprint="$3" + + [ -x "$output" ] \ + && [ -f "$stamp" ] \ + && [ "$(cat "$stamp" 2>/dev/null || true)" = "$fingerprint" ] +} + +mark_target_current() { + local stamp="$1" + local fingerprint="$2" + + mkdir -p "$(dirname "$stamp")" + printf '%s\n' "$fingerprint" > "$stamp" +} + build_host_git() { ensure_git_source bundle_git_manpages + local fingerprint + fingerprint="$(git_source_fingerprint):host" + if target_is_current "$HOST_GIT_BINARY" "$HOST_GIT_STAMP" "$fingerprint"; then + log "Host Git binary is current; skipping rebuild" + "$HOST_GIT_BINARY" --version + return + fi + log "Building Git for development host" cd "$GIT_SRC_DIR" make clean >/dev/null 2>&1 || true @@ -322,6 +359,7 @@ build_host_git() { mkdir -p "$HOST_GIT_DIR" cp git "$HOST_GIT_BINARY" chmod 755 "$HOST_GIT_BINARY" + mark_target_current "$HOST_GIT_STAMP" "$fingerprint" log "Host test Git binary: $HOST_GIT_BINARY" "$HOST_GIT_BINARY" --version @@ -332,9 +370,18 @@ build_android_git_for_abi() { local abi="$1" local cc="$2" local output_dir="$ANDROID_JNI_DIR/$abi" + local output_binary="$output_dir/libgit.so" + local stamp="$output_dir/.source-fingerprint" + local fingerprint="$3:android:$abi:$cc:$ANDROID_API" require_path "$ANDROID_TOOLBIN/$cc" + if target_is_current "$output_binary" "$stamp" "$fingerprint"; then + log "Android $abi Git binary is current; skipping rebuild" + ls -lh "$output_binary" + return + fi + log "Building Git for Android $abi" cd "$GIT_SRC_DIR" make clean >/dev/null 2>&1 || true @@ -357,9 +404,10 @@ build_android_git_for_abi() { readelf -h git | sed -n '1,12p' mkdir -p "$output_dir" - llvm-strip -s git -o "$output_dir/libgit.so" - chmod 755 "$output_dir/libgit.so" - ls -lh "$output_dir/libgit.so" + llvm-strip -s git -o "$output_binary" + chmod 755 "$output_binary" + mark_target_current "$stamp" "$fingerprint" + ls -lh "$output_binary" cd "$PROJECT_DIR" } @@ -369,10 +417,13 @@ build_android_git() { require_path "$ANDROID_NDK_DIR" export PATH="$ANDROID_TOOLBIN:$PATH" - build_android_git_for_abi "arm64-v8a" "aarch64-linux-android${ANDROID_API}-clang" - build_android_git_for_abi "armeabi-v7a" "armv7a-linux-androideabi${ANDROID_API}-clang" - build_android_git_for_abi "x86" "i686-linux-android${ANDROID_API}-clang" - build_android_git_for_abi "x86_64" "x86_64-linux-android${ANDROID_API}-clang" + local fingerprint + fingerprint="$(git_source_fingerprint)" + + build_android_git_for_abi "arm64-v8a" "aarch64-linux-android${ANDROID_API}-clang" "$fingerprint" + build_android_git_for_abi "armeabi-v7a" "armv7a-linux-androideabi${ANDROID_API}-clang" "$fingerprint" + build_android_git_for_abi "x86" "i686-linux-android${ANDROID_API}-clang" "$fingerprint" + build_android_git_for_abi "x86_64" "x86_64-linux-android${ANDROID_API}-clang" "$fingerprint" } build_all_git_targets() { diff --git a/README.md b/README.md index e7f2cdc..09df894 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,8 @@ Options: | Command | Purpose | Output | | --- | --- | --- | -| `bash ./AndroidProjectTooling.sh --test` | Compile Git for the development machine, then run tests with it. | `build/host-git/libgit.so` and test reports | -| `bash ./AndroidProjectTooling.sh --compile-git` | Compile host Git and cross-compile Android ABIs served by Google Play. | Host and Android outputs | +| `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 --compile-git` | Ensure host Git and Android ABI Git binaries are current. | Host and Android outputs | Android ABI outputs: @@ -110,7 +110,9 @@ Android ABI outputs: - `app/src/main/jniLibs/x86/libgit.so` - `app/src/main/jniLibs/x86_64/libgit.so` -The `--test` tooling command automatically builds host Git first and exports `GITHUG_TEST_GIT_BINARY=build/host-git/libgit.so`. This keeps JVM tests on the same `GitRepositoryRuntime` path as the app, including the packaged-runtime helper resolution behavior. +The `--test` tooling command automatically builds host Git only when the Git source checkout has changed, then exports `GITHUG_TEST_GIT_BINARY=build/host-git/libgit.so`. This keeps JVM tests on the same `GitRepositoryRuntime` path as the app, including the packaged-runtime helper resolution behavior. + +Git build outputs are stamped with a Git source fingerprint. Re-running `--test` or `--compile-git` reuses existing `libgit.so` binaries when the checked-out Git sources and target build flavor are unchanged. Git manpage assets are also refreshed from the checked-out Git source whenever Git is compiled or an app artifact is built. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 145032c..f2c337a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "solutions.tretter.githugandroid" minSdk = 26 targetSdk = 35 - versionCode = 132 - versionName = "0.1.131" + versionCode = 133 + versionName = "0.1.132" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/EditorCommands.kt b/app/src/main/java/solutions/tretter/githugandroid/EditorCommands.kt index 023304d..9923bbd 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/EditorCommands.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/EditorCommands.kt @@ -1,6 +1,6 @@ package solutions.tretter.githugandroid -private val VisualEditorCommands = setOf("vi", "nano", "emacs", "ed", "ex") +private val VisualEditorCommands = setOf("vi", "vim", "nano", "emacs", "ed", "ex", "edit", "notepad") data class VisualEditorInvocation( val editor: String, @@ -10,7 +10,9 @@ data class VisualEditorInvocation( fun parseVisualEditorInvocation(command: String): VisualEditorInvocation? { val tokens = GitSandboxEngine.tokenizeCommand(command) - val editor = tokens.firstOrNull()?.takeIf { it in VisualEditorCommands } ?: return null + val editor = tokens.firstOrNull() + ?.takeIf { it.lowercase() in VisualEditorCommands } + ?: return null return VisualEditorInvocation( editor = editor, path = tokens.drop(1).firstOrNull { !it.startsWith("-") }, diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index 44d96ed..0db9f21 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -70,8 +70,6 @@ fun GitHugApp() { var hasRestoredProgress by remember { mutableStateOf(false) } var editorState by remember { mutableStateOf(null) } var gitMessageEditorState by remember { mutableStateOf(null) } - var interactiveAddState by remember { mutableStateOf(null) } - var interactiveRebaseState by remember { mutableStateOf(null) } var manPageState by remember { mutableStateOf(null) } var solvedCelebrationTitle by remember { mutableStateOf(null) } var showTerminalInputHint by remember { mutableStateOf(true) } @@ -211,8 +209,6 @@ fun GitHugApp() { historyDraft = "" editorState = null gitMessageEditorState = null - interactiveAddState = null - interactiveRebaseState = null manPageState = null applyRecommendedPaneWeights(persist = false) } @@ -231,8 +227,6 @@ fun GitHugApp() { historyDraft = "" editorState = null gitMessageEditorState = null - interactiveAddState = null - interactiveRebaseState = null manPageState = null paneLayout = paneLayout.copy( weights = paneLayout.weights + recommendedPaneWeights( @@ -424,59 +418,6 @@ fun GitHugApp() { applyCommandResult(state.invocation.command, newRepo, lines, echoCommand = false) } - fun openInteractiveAdd(invocation: InteractiveAddInvocation) { - output = buildList { - addAll(output) - add("$ ${invocation.command}") - add("Opened interactive add") - } - interactiveAddState = InteractiveAddState( - invocation = invocation, - files = repo.files, - ) - applyRecommendedPaneWeights(persist = false) - } - - fun stageInteractiveSelection(selectedFiles: Set) { - val state = interactiveAddState ?: return - val updatedRepo = repo.copy( - files = repo.files.map { file -> - if (file.name in selectedFiles) file.copy(staged = true) else file - }, - ) - interactiveAddState = null - val lines = if (selectedFiles.isEmpty()) { - listOf("No changes staged") - } else { - listOf("staged ${selectedFiles.size} path(s)") + selectedFiles.sorted().map { " $it" } - } - applyCommandResult(state.invocation.command, updatedRepo, lines, echoCommand = false) - } - - fun openInteractiveRebase(invocation: InteractiveRebaseInvocation) { - val count = invocation.commitCount ?: repo.commits.size - val commits = repo.commits.takeLast(count.coerceAtLeast(0)) - output = buildList { - addAll(output) - add("$ ${invocation.command}") - add("Opened interactive rebase") - } - interactiveRebaseState = InteractiveRebaseState(invocation = invocation, commits = commits) - applyRecommendedPaneWeights(persist = false) - } - - fun applyInteractiveRebaseSelection(drafts: List) { - val state = interactiveRebaseState ?: return - interactiveRebaseState = null - val newRepo = applyInteractiveRebase(repo, drafts) - applyCommandResult( - raw = state.invocation.command, - newRepo = newRepo, - lines = listOf("Successfully rebased and updated ${repo.headBranch}."), - echoCommand = false, - ) - } - fun runCommand() { val submittedText = commandInput.text val raw = submittedText.trim() @@ -512,18 +453,6 @@ fun GitHugApp() { return } - val interactiveAddInvocation = parseInteractiveAddInvocation(raw) - if (interactiveAddInvocation != null) { - openInteractiveAdd(interactiveAddInvocation) - return - } - - val interactiveRebaseInvocation = parseInteractiveRebaseInvocation(raw) - if (interactiveRebaseInvocation != null) { - openInteractiveRebase(interactiveRebaseInvocation) - return - } - val (newRepo, lines) = runtime.execute(currentLevel, repo, raw) applyCommandResult(raw, newRepo, lines, echoCommand = true) } @@ -686,28 +615,6 @@ fun GitHugApp() { ) } - interactiveAddState?.let { state -> - InteractiveAddDialog( - state = state, - onClose = { - output = output + "Interactive add closed without staging" - interactiveAddState = null - }, - onStage = { selectedFiles -> stageInteractiveSelection(selectedFiles) }, - ) - } - - interactiveRebaseState?.let { state -> - InteractiveRebaseDialog( - state = state, - onClose = { - output = output + "Interactive rebase closed without applying" - interactiveRebaseState = null - }, - onApply = { drafts -> applyInteractiveRebaseSelection(drafts) }, - ) - } - manPageState?.let { state -> ManPageDialog( state = state, diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 5c950b4..563f4a7 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -176,7 +176,7 @@ class GitRepositoryRuntime private constructor( add(" binary path: nativeLibraryDir/libgit.so") add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}") add(" helper commands: ls/dir, pwd, cat, touch, mkdir/md, cd.., rm/del, echo") - add(" visual editors: vi, nano, emacs, ed, ex") + add(" visual editors: vi, vim, nano, emacs, ed, ex, edit, notepad") add(" git help ") } } @@ -548,7 +548,7 @@ class GitRepositoryRuntime private constructor( } val shouldUseSandboxSemantics = when (gitCommand) { "add" -> tokens.any { it == "-p" || it == "--patch" || it == "-i" || it == "--interactive" } - "rebase" -> "-i" in tokens || "--onto" in tokens + "rebase" -> "-i" in tokens || "--interactive" in tokens || "--onto" in tokens "merge" -> "--squash" in tokens || tokens.lastOrNull() == "mybranch" || tokens.lastOrNull() == "feature" "revert", "stash" -> true "checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb") diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt index 54d6a9a..00b7a07 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt @@ -165,10 +165,13 @@ object GitSandboxEngine { private fun interactiveAdd(repo: RepoState, arguments: List): Pair> { val targets = arguments.filterNot { it == "-i" || it == "--interactive" || it.startsWith("--") } val target = targets.lastOrNull() + val candidates = repo.files.filter { file -> + !file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/")) + } val updated = repo.files.map { file -> if (file.deleted) { file - } else if (target == null || target == "." || file.name == target) { + } else if (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/")) { file.copy(staged = true) } else { file @@ -178,10 +181,32 @@ object GitSandboxEngine { val before = repo.files.firstOrNull { it.name == updatedFile.name } updatedFile.staged && before?.staged != true } - return repo.copy(files = updated) to listOf( - "Interactive add is handled by GitHug Android.", - "Staged ${if (target == null || target == ".") "$stagedCount file(s)" else target}.", - ) + return repo.copy(files = updated) to interactiveAddConsoleLines(candidates, target, stagedCount) + } + + private fun interactiveAddConsoleLines(candidates: List, target: String?, stagedCount: Int): List { + return buildList { + add(" staged unstaged path") + candidates.forEachIndexed { index, file -> + val staged = if (file.staged) "unchanged" else "+0/-0" + val unstaged = when { + file.tracked -> "+1/-0" + else -> "+0/-0" + } + add("${index + 1}: ${staged.padEnd(10)} ${unstaged.padEnd(9)} ${file.name}") + } + if (candidates.isEmpty()) { + add("No changes.") + } + add("*** Commands ***") + add(" 1: status 2: update 3: revert 4: add untracked") + add(" 5: patch 6: diff 7: quit 8: help") + add("What now> update") + add("Update>> ${target ?: "*"}") + add("updated ${if (target == null || target == ".") "$stagedCount path(s)" else target}") + add("What now> quit") + add("Bye.") + } } fun tokenizeCommand(command: String): List { @@ -434,7 +459,9 @@ object GitSandboxEngine { } private fun rebase(repo: RepoState, arguments: List): Pair> { - val commits = if ("-i" in arguments && repo.commits.size > 2) { + val interactive = "-i" in arguments || "--interactive" in arguments + val originalCommits = repo.commits + val commits = if (interactive && repo.commits.size > 2) { repo.commits .filterNot { it.message.contains("squash this commit", ignoreCase = true) } .map { if (it.message == "First coommit") it.copy(message = "First commit") else it } @@ -465,7 +492,31 @@ object GitSandboxEngine { } else { repo.maintenanceActions } - return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to emptyList() + val output = if (interactive) interactiveRebaseConsoleLines(originalCommits, commits, arguments, repo.headBranch) else emptyList() + return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to output + } + + private fun interactiveRebaseConsoleLines(originalCommits: List, rebasedCommits: List, arguments: List, branch: String): List { + val target = arguments.lastOrNull { it != "-i" && it != "--interactive" }.orEmpty() + val rebasedIds = rebasedCommits.map { it.id }.toSet() + return buildList { + originalCommits.forEach { commit -> + val action = when { + commit.id !in rebasedIds -> "squash" + commit.message == "First coommit" -> "reword" + else -> "pick" + } + add("$action ${commit.id} ${commit.message}") + } + add("") + add("# Rebase ${target.ifBlank { "HEAD" }} in progress; onto HEAD") + add("# Commands:") + add("# p, pick = use commit") + add("# r, reword = use commit, but edit the commit message") + add("# s, squash = use commit, but meld into previous commit") + add("# d, drop = remove commit") + add("Successfully rebased and updated refs/heads/$branch.") + } } private fun commit(repo: RepoState, arguments: List): Pair> { diff --git a/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddCommands.kt b/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddCommands.kt deleted file mode 100644 index 50c6ffe..0000000 --- a/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddCommands.kt +++ /dev/null @@ -1,21 +0,0 @@ -package solutions.tretter.githugandroid - -data class InteractiveAddInvocation( - val command: String, - val pathspec: String?, -) - -fun parseInteractiveAddInvocation(command: String): InteractiveAddInvocation? { - val tokens = GitSandboxEngine.tokenizeCommand(command) - if (tokens.size < 3 || tokens.firstOrNull() != "git") return null - - val subcommand = tokens.getOrNull(1) - if (subcommand != "add" && subcommand != "stage") return null - if (tokens.drop(2).none { it == "-i" || it == "--interactive" }) return null - - val pathspec = tokens - .drop(2) - .firstOrNull { token -> token != "-i" && token != "--interactive" && !token.startsWith("-") } - - return InteractiveAddInvocation(command = command, pathspec = pathspec) -} diff --git a/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddDialog.kt deleted file mode 100644 index 078ccdb..0000000 --- a/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddDialog.kt +++ /dev/null @@ -1,181 +0,0 @@ -package solutions.tretter.githugandroid - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CheckboxDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog - -data class InteractiveAddState( - val invocation: InteractiveAddInvocation, - val files: List, -) - -@Composable -fun InteractiveAddDialog( - state: InteractiveAddState, - onClose: () -> Unit, - onStage: (Set) -> Unit, -) { - val candidates = remember(state.files, state.invocation.pathspec) { - state.files - .filter { file -> !file.staged } - .filter { file -> - val pathspec = state.invocation.pathspec - pathspec == null || pathspec == "." || file.name == pathspec || file.name.startsWith(pathspec.trimEnd('/') + "/") - } - } - var selectedFiles by remember(candidates) { - mutableStateOf(candidates.map { it.name }.toSet()) - } - val scrollState = rememberScrollState() - - Dialog(onDismissRequest = onClose) { - Surface( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.76f) - .imePadding(), - color = PanelPrimary, - shape = RoundedCornerShape(8.dp), - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text( - text = "Interactive Add", - color = TextPrimary, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) - Text( - text = state.invocation.command, - color = TextSecondary, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - DialogButton(label = "Close", onClick = onClose) - DialogButton( - label = "Stage", - enabled = selectedFiles.isNotEmpty(), - onClick = { onStage(selectedFiles) }, - ) - DialogButton( - label = "All", - enabled = candidates.isNotEmpty(), - onClick = { selectedFiles = candidates.map { it.name }.toSet() }, - ) - DialogButton( - label = "None", - enabled = selectedFiles.isNotEmpty(), - onClick = { selectedFiles = emptySet() }, - ) - } - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .background(TerminalBackground, RoundedCornerShape(6.dp)) - .verticalScroll(scrollState) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (candidates.isEmpty()) { - Text( - text = "No changes available to stage.", - color = TextSecondary, - fontFamily = FontFamily.Monospace, - ) - } else { - candidates.forEach { file -> - val checked = file.name in selectedFiles - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox( - checked = checked, - onCheckedChange = { isChecked -> - selectedFiles = if (isChecked) { - selectedFiles + file.name - } else { - selectedFiles - file.name - } - }, - colors = CheckboxDefaults.colors( - checkedColor = Accent, - uncheckedColor = TextMuted, - checkmarkColor = AppBackground, - ), - ) - Text( - text = "${file.statusLabel()} ${file.name}", - color = TextPrimary, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - ) - } - } - } - } - } - } - } -} - -private fun GitFile.statusLabel(): String { - return when { - deleted -> "deleted: " - tracked -> "modified: " - else -> "new file: " - } -} - -@Composable -private fun DialogButton( - label: String, - enabled: Boolean = true, - onClick: () -> Unit, -) { - Button( - onClick = onClick, - enabled = enabled, - colors = ButtonDefaults.buttonColors( - containerColor = Accent, - contentColor = AppBackground, - disabledContainerColor = PanelTertiary, - disabledContentColor = TextMuted, - ), - ) { - Text(label) - } -} diff --git a/app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseCommands.kt b/app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseCommands.kt deleted file mode 100644 index 4fa27f5..0000000 --- a/app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseCommands.kt +++ /dev/null @@ -1,25 +0,0 @@ -package solutions.tretter.githugandroid - -data class InteractiveRebaseInvocation( - val command: String, - val commitCount: Int?, -) - -fun parseInteractiveRebaseInvocation(command: String): InteractiveRebaseInvocation? { - val tokens = GitSandboxEngine.tokenizeCommand(command) - if (tokens.size < 3 || tokens.firstOrNull() != "git") return null - if (tokens.getOrNull(1) != "rebase") return null - if (tokens.drop(2).none { it == "-i" || it == "--interactive" }) return null - - val commitCount = tokens - .drop(2) - .firstNotNullOfOrNull { token -> - Regex("""HEAD[~^](\d+)""") - .matchEntire(token) - ?.groupValues - ?.getOrNull(1) - ?.toIntOrNull() - } - - return InteractiveRebaseInvocation(command = command, commitCount = commitCount) -} diff --git a/app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseDialog.kt b/app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseDialog.kt deleted file mode 100644 index 5a9fde9..0000000 --- a/app/src/main/java/solutions/tretter/githugandroid/InteractiveRebaseDialog.kt +++ /dev/null @@ -1,238 +0,0 @@ -package solutions.tretter.githugandroid - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog - -data class InteractiveRebaseState( - val invocation: InteractiveRebaseInvocation, - val commits: List, -) - -enum class RebaseAction(val label: String) { - PICK("pick"), - SQUASH("squash"), - DROP("drop"), -} - -data class RebaseCommitDraft( - val commit: CommitNode, - val message: String, - val action: RebaseAction, -) - -@Composable -fun InteractiveRebaseDialog( - state: InteractiveRebaseState, - onClose: () -> Unit, - onApply: (List) -> Unit, -) { - val scrollState = rememberScrollState() - var drafts by remember(state.commits) { - mutableStateOf(state.commits.map { commit -> - RebaseCommitDraft( - commit = commit, - message = if (commit.message == "First coommit") "First commit" else commit.message, - action = if (commit.message.contains("squash this commit", ignoreCase = true)) { - RebaseAction.SQUASH - } else { - RebaseAction.PICK - }, - ) - }.withSuggestedOrder()) - } - - Dialog(onDismissRequest = onClose) { - Surface( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.84f) - .imePadding(), - color = PanelPrimary, - shape = RoundedCornerShape(8.dp), - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text( - text = "Interactive Rebase", - color = TextPrimary, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) - Text( - text = state.invocation.command, - color = TextSecondary, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - RebaseButton(label = "Close", onClick = onClose) - RebaseButton(label = "Apply", onClick = { onApply(drafts) }) - } - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .background(TerminalBackground, RoundedCornerShape(6.dp)) - .verticalScroll(scrollState) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (drafts.isEmpty()) { - Text( - text = "No commits available for interactive rebase.", - color = TextSecondary, - fontFamily = FontFamily.Monospace, - ) - } - drafts.forEachIndexed { index, draft -> - Column( - modifier = Modifier - .fillMaxWidth() - .background(PanelSecondary, RoundedCornerShape(6.dp)) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - text = "${draft.action.label} ${draft.commit.id}", - color = Accent, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - ) - OutlinedTextField( - value = draft.message, - onValueChange = { message -> - drafts = drafts.replaceAt(index, draft.copy(message = message)) - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), - ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - RebaseButton( - label = "Pick", - enabled = draft.action != RebaseAction.PICK, - onClick = { drafts = drafts.replaceAt(index, draft.copy(action = RebaseAction.PICK)) }, - ) - RebaseButton( - label = "Squash", - enabled = draft.action != RebaseAction.SQUASH && index > 0, - onClick = { drafts = drafts.replaceAt(index, draft.copy(action = RebaseAction.SQUASH)) }, - ) - RebaseButton( - label = "Drop", - enabled = draft.action != RebaseAction.DROP, - onClick = { drafts = drafts.replaceAt(index, draft.copy(action = RebaseAction.DROP)) }, - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - RebaseButton( - label = "Up", - enabled = index > 0, - onClick = { drafts = drafts.move(index, index - 1) }, - ) - RebaseButton( - label = "Down", - enabled = index < drafts.lastIndex, - onClick = { drafts = drafts.move(index, index + 1) }, - ) - } - } - } - } - } - } - } -} - -fun applyInteractiveRebase(repo: RepoState, drafts: List): RepoState { - val rebasedIds = drafts.map { it.commit.id }.toSet() - val untouched = repo.commits.filterNot { it.id in rebasedIds } - val rebased = drafts.fold(emptyList()) { commits, draft -> - when (draft.action) { - RebaseAction.PICK -> commits + draft.commit.copy(message = draft.message) - RebaseAction.DROP -> commits - RebaseAction.SQUASH -> commits - } - } - val nextCommits = untouched + rebased - return repo.copy( - commits = nextCommits, - branches = repo.branches + (repo.headBranch to nextCommits.size), - ) -} - -private fun List.replaceAt(index: Int, draft: RebaseCommitDraft): List { - return mapIndexed { currentIndex, currentDraft -> if (currentIndex == index) draft else currentDraft } -} - -private fun List.move(from: Int, to: Int): List { - return toMutableList().also { drafts -> - val item = drafts.removeAt(from) - drafts.add(to, item) - } -} - -private fun List.withSuggestedOrder(): List { - val messages = map { it.message } - if (!messages.containsAll(listOf("First commit", "Second commit", "Third commit"))) return this - return sortedBy { draft -> - when (draft.message) { - "First commit" -> 1 - "Second commit" -> 2 - "Third commit" -> 3 - else -> 0 - } - } -} - -@Composable -private fun RebaseButton( - label: String, - enabled: Boolean = true, - onClick: () -> Unit, -) { - Button( - onClick = onClick, - enabled = enabled, - colors = ButtonDefaults.buttonColors( - containerColor = Accent, - contentColor = AppBackground, - disabledContainerColor = PanelTertiary, - disabledContentColor = TextMuted, - ), - ) { - Text(label) - } -} diff --git a/app/src/test/java/solutions/tretter/githugandroid/EditorCommandsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/EditorCommandsTest.kt index 31ef734..5289212 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/EditorCommandsTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/EditorCommandsTest.kt @@ -14,6 +14,13 @@ class EditorCommandsTest { assertEquals("vi .gitignore", invocation?.command) } + @Test + fun parsesAdditionalEditorAliases() { + assertEquals("vim", parseVisualEditorInvocation("vim .gitignore")?.editor) + assertEquals("edit", parseVisualEditorInvocation("edit README")?.editor) + assertEquals("notepad", parseVisualEditorInvocation("notepad notes.txt")?.editor) + } + @Test fun ignoresEditorOptionsWhenChoosingPath() { val invocation = parseVisualEditorInvocation("nano -w README") diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index 75f3c87..c85a902 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -277,13 +277,14 @@ class GitSandboxEngineTest { } @Test - fun interactiveStageDoesNotThrow() { + fun interactiveStageUsesConsoleText() { val repo = RepoState(initialized = true, files = listOf(GitFile("README"))) val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i") assertTrue(updatedRepo.files.single { it.name == "README" }.staged) - assertTrue(output.any { it.contains("Interactive add is handled by GitHug Android") }) + assertTrue(output.any { it.contains("What now>") }) + assertFalse(output.any { it.contains("GitHug Android") }) } private fun testGitBinary(): File {