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" applicationId = "solutions.tretter.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 147 versionCode = 148
versionName = "0.1.146" versionName = "0.1.147"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

View File

@@ -2,6 +2,7 @@ package solutions.tretter.githugandroid
enum class GitEditorCommandKind { enum class GitEditorCommandKind {
COMMIT_MESSAGE, COMMIT_MESSAGE,
REBASE_TODO,
TAG_MESSAGE, TAG_MESSAGE,
} }
@@ -18,6 +19,7 @@ fun parseGitEditorInvocation(command: String): GitEditorInvocation? {
return when (tokens[1]) { return when (tokens[1]) {
"commit" -> parseGitCommitEditor(command, tokens.drop(2)) "commit" -> parseGitCommitEditor(command, tokens.drop(2))
"rebase" -> parseGitRebaseEditor(command, tokens.drop(2))
"tag" -> parseGitTagEditor(command, tokens.drop(2)) "tag" -> parseGitTagEditor(command, tokens.drop(2))
else -> null 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? { private fun parseGitTagEditor(command: String, arguments: List<String>): GitEditorInvocation? {
val needsMessage = arguments.any { it == "-a" || it == "-s" || it == "--annotate" || it == "--sign" } val needsMessage = arguments.any { it == "-a" || it == "-s" || it == "--annotate" || it == "--sign" }
if (!needsMessage) return null if (!needsMessage) return null

View File

@@ -404,14 +404,15 @@ fun GitHugApp() {
} }
fun openGitMessageEditor(invocation: GitEditorInvocation) { fun openGitMessageEditor(invocation: GitEditorInvocation) {
val initialContent = runtime.gitEditorInitialContent(currentLevel, repo, invocation)
output = buildList { output = buildList {
addAll(output) addAll(output)
add("$ ${invocation.command}") 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( gitMessageEditorState = GitMessageEditorState(
invocation = invocation, invocation = invocation.copy(initialContent = initialContent),
content = invocation.initialContent, content = initialContent,
) )
applyRecommendedPaneWeights(persist = false) applyRecommendedPaneWeights(persist = false)
} }

View File

@@ -247,6 +247,39 @@ class GitRepositoryRuntime private constructor(
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to listOf("Saved $path") 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( fun executeGitEditorCommand(
level: Level, level: Level,
currentRepo: RepoState, currentRepo: RepoState,
@@ -262,6 +295,10 @@ class GitRepositoryRuntime private constructor(
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot .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 { val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply {
parentFile?.mkdirs() parentFile?.mkdirs()
writeText(message) writeText(message)
@@ -276,6 +313,44 @@ class GitRepositoryRuntime private constructor(
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to result.outputLines 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>> { private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
return when (tokens.first()) { return when (tokens.first()) {
"ls", "dir" -> currentRepo to workingDir.listFiles() "ls", "dir" -> currentRepo to workingDir.listFiles()
@@ -852,12 +927,22 @@ class GitRepositoryRuntime private constructor(
return EnvironmentPrefixedCommand(environment, tokens.drop(commandStart)) 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 { private fun String.isShellEnvironmentName(): Boolean {
if (isEmpty()) return false if (isEmpty()) return false
if (first() != '_' && !first().isLetter()) return false if (first() != '_' && !first().isLetter()) return false
return all { it == '_' || it.isLetterOrDigit() } return all { it == '_' || it.isLetterOrDigit() }
} }
private fun String.toShellSingleQuoted(): String {
return "'" + replace("'", "'\"'\"'") + "'"
}
private fun runGit( private fun runGit(
binary: File, binary: File,
workingDir: File, workingDir: File,

View File

@@ -23,6 +23,19 @@ class GitEditorCommandsTest {
assertNull(parseGitEditorInvocation("git commit --amend --no-edit")) 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 @Test
fun annotatedTagWithoutMessageOpensEditor() { fun annotatedTagWithoutMessageOpensEditor() {
val invocation = parseGitEditorInvocation("git tag -a v1.0") 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 { private fun testGitBinary(): File {
System.getenv("GITHUG_TEST_GIT_BINARY") System.getenv("GITHUG_TEST_GIT_BINARY")
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }