Open app editor for interactive rebase todo

This commit is contained in:
Joe Tretter
2026-05-18 19:01:12 -05:00
parent d4e2c8714c
commit ce58f46b48
6 changed files with 148 additions and 5 deletions

View File

@@ -19,8 +19,8 @@ android {
applicationId = "solutions.tretter.githugandroid"
minSdk = 26
targetSdk = 35
versionCode = 147
versionName = "0.1.146"
versionCode = 148
versionName = "0.1.147"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true

View File

@@ -2,6 +2,7 @@ package solutions.tretter.githugandroid
enum class GitEditorCommandKind {
COMMIT_MESSAGE,
REBASE_TODO,
TAG_MESSAGE,
}
@@ -18,6 +19,7 @@ fun parseGitEditorInvocation(command: String): GitEditorInvocation? {
return when (tokens[1]) {
"commit" -> parseGitCommitEditor(command, tokens.drop(2))
"rebase" -> parseGitRebaseEditor(command, tokens.drop(2))
"tag" -> parseGitTagEditor(command, tokens.drop(2))
else -> null
}
@@ -35,6 +37,19 @@ private fun parseGitCommitEditor(command: String, arguments: List<String>): GitE
)
}
private fun parseGitRebaseEditor(command: String, arguments: List<String>): GitEditorInvocation? {
val interactive = arguments.any { it == "-i" || it == "--interactive" }
if (!interactive) return null
val target = arguments.lastOrNull { it != "-i" && it != "--interactive" && !it.startsWith("-") }
if (target == null) return null
return GitEditorInvocation(
command = command,
kind = GitEditorCommandKind.REBASE_TODO,
title = "Edit Rebase Todo",
)
}
private fun parseGitTagEditor(command: String, arguments: List<String>): GitEditorInvocation? {
val needsMessage = arguments.any { it == "-a" || it == "-s" || it == "--annotate" || it == "--sign" }
if (!needsMessage) return null

View File

@@ -404,14 +404,15 @@ fun GitHugApp() {
}
fun openGitMessageEditor(invocation: GitEditorInvocation) {
val initialContent = runtime.gitEditorInitialContent(currentLevel, repo, invocation)
output = buildList {
addAll(output)
add("$ ${invocation.command}")
add("Opened Git message editor")
add(if (invocation.kind == GitEditorCommandKind.REBASE_TODO) "Opened Git rebase editor" else "Opened Git message editor")
}
gitMessageEditorState = GitMessageEditorState(
invocation = invocation,
content = invocation.initialContent,
invocation = invocation.copy(initialContent = initialContent),
content = initialContent,
)
applyRecommendedPaneWeights(persist = false)
}

View File

@@ -247,6 +247,39 @@ class GitRepositoryRuntime private constructor(
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to listOf("Saved $path")
}
fun gitEditorInitialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
if (invocation.kind != GitEditorCommandKind.REBASE_TODO) return invocation.initialContent
val nativeGit = requireNativeGit()
val sandboxRoot = sandboxDir(level).canonicalFile
if (!sandboxRoot.exists()) {
prepareLevel(level)
}
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
val target = rebaseTarget(invocation.command) ?: return invocation.initialContent
val result = runGit(nativeGit, workingDir, listOf("log", "--reverse", "--format=%h %s", "$target..HEAD"))
if (result.exitCode != 0 || result.outputLines.isEmpty()) return invocation.initialContent
return buildString {
result.outputLines
.filter { it.isNotBlank() }
.forEach { line -> appendLine("pick $line") }
appendLine()
appendLine("# Rebase $target..HEAD onto $target")
appendLine("#")
appendLine("# Commands:")
appendLine("# p, pick <commit> = use commit")
appendLine("# r, reword <commit> = use commit, but edit the commit message")
appendLine("# e, edit <commit> = use commit, but stop for amending")
appendLine("# s, squash <commit> = use commit, but meld into previous commit")
appendLine("# f, fixup [-C | -c] <commit> = like squash but keep only the previous commit's log message")
appendLine("# d, drop <commit> = remove commit")
appendLine("#")
appendLine("# These lines can be re-ordered; they are executed from top to bottom.")
}
}
fun executeGitEditorCommand(
level: Level,
currentRepo: RepoState,
@@ -262,6 +295,10 @@ class GitRepositoryRuntime private constructor(
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
if (invocation.kind == GitEditorCommandKind.REBASE_TODO) {
return executeInteractiveRebaseEditorCommand(level, currentRepo, nativeGit, sandboxRoot, workingDir, invocation, message)
}
val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply {
parentFile?.mkdirs()
writeText(message)
@@ -276,6 +313,44 @@ class GitRepositoryRuntime private constructor(
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to result.outputLines
}
private fun executeInteractiveRebaseEditorCommand(
level: Level,
currentRepo: RepoState,
nativeGit: File,
sandboxRoot: File,
workingDir: File,
invocation: GitEditorInvocation,
todo: String,
): Pair<RepoState, List<String>> {
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
writeText(todo)
}
val editorScript = File(gitDir, "githug-android-sequence-editor.sh").apply {
writeText(
"""
|#!/bin/sh
|cat ${todoFile.absolutePath.toShellSingleQuoted()} > "$1"
|""".trimMargin(),
)
setExecutable(true, true)
}
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command).drop(1)
val result = runGit(
nativeGit,
workingDir,
arguments,
mapOf(
"GIT_SEQUENCE_EDITOR" to editorScript.absolutePath,
"GIT_EDITOR" to "true",
),
)
todoFile.delete()
editorScript.delete()
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to result.outputLines
}
private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
return when (tokens.first()) {
"ls", "dir" -> currentRepo to workingDir.listFiles()
@@ -852,12 +927,22 @@ class GitRepositoryRuntime private constructor(
return EnvironmentPrefixedCommand(environment, tokens.drop(commandStart))
}
private fun rebaseTarget(command: String): String? {
val tokens = GitSandboxEngine.tokenizeCommand(command)
if (tokens.size < 3 || tokens[0] != "git" || tokens[1] != "rebase") return null
return tokens.drop(2).lastOrNull { it != "-i" && it != "--interactive" && !it.startsWith("-") }
}
private fun String.isShellEnvironmentName(): Boolean {
if (isEmpty()) return false
if (first() != '_' && !first().isLetter()) return false
return all { it == '_' || it.isLetterOrDigit() }
}
private fun String.toShellSingleQuoted(): String {
return "'" + replace("'", "'\"'\"'") + "'"
}
private fun runGit(
binary: File,
workingDir: File,

View File

@@ -23,6 +23,19 @@ class GitEditorCommandsTest {
assertNull(parseGitEditorInvocation("git commit --amend --no-edit"))
}
@Test
fun interactiveRebaseOpensTodoEditor() {
val invocation = parseGitEditorInvocation("git rebase -i HEAD~3")
assertEquals(GitEditorCommandKind.REBASE_TODO, invocation?.kind)
assertEquals("Edit Rebase Todo", invocation?.title)
}
@Test
fun bareInteractiveRebaseDoesNotOpenTodoEditor() {
assertNull(parseGitEditorInvocation("git rebase -i"))
}
@Test
fun annotatedTagWithoutMessageOpensEditor() {
val invocation = parseGitEditorInvocation("git tag -a v1.0")

View File

@@ -382,6 +382,35 @@ class GitSandboxEngineTest {
}
}
@Test
fun nativeInteractiveRebaseUsesAppSequenceEditorContent() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-interactive-rebase-editor").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = reorderLevel()
val repo = runtime.prepareLevel(level)
val invocation = parseGitEditorInvocation("git rebase -i HEAD~3")
?: error("Expected interactive rebase editor invocation")
val todo = runtime.gitEditorInitialContent(level, repo, invocation)
val reorderedTodo = todo.lines().toMutableList().also { lines ->
val secondPick = lines.indexOfFirst { it.contains("Third commit") }
val thirdPick = lines.indexOfFirst { it.contains("Second commit") }
val thirdLine = lines[thirdPick]
lines[thirdPick] = lines[secondPick]
lines[secondPick] = thirdLine
}.joinToString("\n")
val (updatedRepo, output) = runtime.executeGitEditorCommand(level, repo, invocation, reorderedTodo)
assertFalse(output.any { it.contains("Terminal is dumb", ignoreCase = true) })
assertTrue(level.validator(updatedRepo, invocation.command))
} finally {
root.deleteRecursively()
}
}
private fun testGitBinary(): File {
System.getenv("GITHUG_TEST_GIT_BINARY")
?.takeIf { it.isNotBlank() }