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