Misc Changes

This commit is contained in:
Joe Tretter
2026-05-09 22:49:15 -05:00
parent f49e8a96b1
commit 8f829fb969
13 changed files with 139 additions and 583 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -70,8 +70,6 @@ fun GitHugApp() {
var hasRestoredProgress by remember { mutableStateOf(false) }
var editorState by remember { mutableStateOf<TextEditorState?>(null) }
var gitMessageEditorState by remember { mutableStateOf<GitMessageEditorState?>(null) }
var interactiveAddState by remember { mutableStateOf<InteractiveAddState?>(null) }
var interactiveRebaseState by remember { mutableStateOf<InteractiveRebaseState?>(null) }
var manPageState by remember { mutableStateOf<ManPageState?>(null) }
var solvedCelebrationTitle by remember { mutableStateOf<String?>(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<String>) {
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<RebaseCommitDraft>) {
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,

View File

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

View File

@@ -165,10 +165,13 @@ object GitSandboxEngine {
private fun interactiveAdd(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
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<GitFile>, target: String?, stagedCount: Int): List<String> {
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<String> {
@@ -434,7 +459,9 @@ object GitSandboxEngine {
}
private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
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<CommitNode>, rebasedCommits: List<CommitNode>, arguments: List<String>, branch: String): List<String> {
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 <commit> = use commit")
add("# r, reword <commit> = use commit, but edit the commit message")
add("# s, squash <commit> = use commit, but meld into previous commit")
add("# d, drop <commit> = remove commit")
add("Successfully rebased and updated refs/heads/$branch.")
}
}
private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {

View File

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

View File

@@ -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<GitFile>,
)
@Composable
fun InteractiveAddDialog(
state: InteractiveAddState,
onClose: () -> Unit,
onStage: (Set<String>) -> 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)
}
}

View File

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

View File

@@ -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<CommitNode>,
)
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<RebaseCommitDraft>) -> 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<RebaseCommitDraft>): RepoState {
val rebasedIds = drafts.map { it.commit.id }.toSet()
val untouched = repo.commits.filterNot { it.id in rebasedIds }
val rebased = drafts.fold(emptyList<CommitNode>()) { 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<RebaseCommitDraft>.replaceAt(index: Int, draft: RebaseCommitDraft): List<RebaseCommitDraft> {
return mapIndexed { currentIndex, currentDraft -> if (currentIndex == index) draft else currentDraft }
}
private fun List<RebaseCommitDraft>.move(from: Int, to: Int): List<RebaseCommitDraft> {
return toMutableList().also { drafts ->
val item = drafts.removeAt(from)
drafts.add(to, item)
}
}
private fun List<RebaseCommitDraft>.withSuggestedOrder(): List<RebaseCommitDraft> {
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)
}
}

View File

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

View File

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