From ce58f46b48d41c9b4b734393a0242e414fa16d89 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Mon, 18 May 2026 19:01:12 -0500 Subject: [PATCH] Open app editor for interactive rebase todo --- app/build.gradle.kts | 4 +- .../githugandroid/GitEditorCommands.kt | 15 ++++ .../tretter/githugandroid/GitHugApp.kt | 7 +- .../tretter/githugandroid/GitRuntime.kt | 85 +++++++++++++++++++ .../githugandroid/GitEditorCommandsTest.kt | 13 +++ .../githugandroid/GitSandboxEngineTest.kt | 29 +++++++ 6 files changed, 148 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0825b2a..3dacbd8 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 = 147 - versionName = "0.1.146" + versionCode = 148 + versionName = "0.1.147" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt b/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt index a627638..3826833 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt @@ -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): GitE ) } +private fun parseGitRebaseEditor(command: String, arguments: List): 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): GitEditorInvocation? { val needsMessage = arguments.any { it == "-a" || it == "-s" || it == "--annotate" || it == "--sign" } if (!needsMessage) return null diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index c94cc51..c6e3775 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -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) } diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 547818d..aa5a2f5 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -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 = use commit") + appendLine("# r, reword = use commit, but edit the commit message") + appendLine("# e, edit = use commit, but stop for amending") + appendLine("# s, squash = use commit, but meld into previous commit") + appendLine("# f, fixup [-C | -c] = like squash but keep only the previous commit's log message") + appendLine("# d, drop = 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> { + 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): Pair> { 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, diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitEditorCommandsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitEditorCommandsTest.kt index 79b60bc..c003d90 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitEditorCommandsTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitEditorCommandsTest.kt @@ -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") diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index 63397d6..8f09f09 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -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() }