Open app editor for interactive rebase todo
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user