Fix Git editor reword workflow and UI coverage

- show Git editor file paths instead of the submitted git command
- surface follow-up commit message editors during interactive rebase reword
- add rename-commit UI tests for editor and amend-message solution paths
- add broader embedded level solution scenarios for alternate Git workflows
This commit is contained in:
Joe Tretter
2026-06-26 11:23:16 -05:00
parent 2db8fdd328
commit 781b5090f7
49 changed files with 372 additions and 19 deletions

View File

@@ -20,8 +20,8 @@ android {
applicationId = "solutions.tretter.githugandroid" applicationId = "solutions.tretter.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 178 versionCode = 179
versionName = "0.1.177" versionName = "0.1.178"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

View File

@@ -4,11 +4,16 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.text.AnnotatedString
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
@@ -44,12 +49,41 @@ class GitRepositoryRuntimeInstrumentedTest {
launchFreshApp().use { launchFreshApp().use {
allGithugLevels().forEachIndexed { index, level -> allGithugLevels().forEachIndexed { index, level ->
waitForExercise(level) waitForExercise(level)
level.testCases.first().commands.forEach(::submitTerminalCommand) if (level.id == renameCommitLevel().id) {
completeRenameCommitWithEditor()
} else {
level.testCases.first().commands.forEach(::submitTerminalCommand)
}
waitForLevelCompletion(nextLevel = allGithugLevels().getOrNull(index + 1)) waitForLevelCompletion(nextLevel = allGithugLevels().getOrNull(index + 1))
} }
} }
} }
@Test
fun renameCommitLevelCompletesThroughUiEditor() {
launchFreshApp().use {
selectLevel(renameCommitLevel())
completeRenameCommitWithEditor()
waitForLevelCompletion(nextLevel = initLevel())
}
}
@Test
fun renameCommitLevelCompletesThroughMessageOptionAfterReword() {
launchFreshApp().use {
selectLevel(renameCommitLevel())
startRenameCommitReword()
composeRule.onNodeWithTag("git-message-editor-dismiss").performClick()
submitTerminalCommand("git commit --amend -m \"First commit\"")
submitTerminalCommand("git rebase --continue")
waitForLevelCompletion(nextLevel = initLevel())
}
}
@Test @Test
fun configLevelCompletesThroughUiWithArbitraryValues() { fun configLevelCompletesThroughUiWithArbitraryValues() {
launchFreshApp().use { launchFreshApp().use {
@@ -111,6 +145,56 @@ class GitRepositoryRuntimeInstrumentedTest {
composeRule.onNodeWithTag("exercise-title-${nextLevel.id}").assertIsDisplayed() composeRule.onNodeWithTag("exercise-title-${nextLevel.id}").assertIsDisplayed()
} }
private fun selectLevel(level: Level) {
composeRule.onNodeWithTag("level-${level.id}")
.performScrollTo()
.performClick()
waitForExercise(level)
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodes(hasText("Loaded level: ${level.title}", substring = true))
.fetchSemanticsNodes()
.isNotEmpty()
}
}
private fun completeRenameCommitWithEditor() {
startRenameCommitReword()
waitForGitEditorPath(".git/COMMIT_EDITMSG")
replaceGitEditorContent(
currentGitEditorContent().replaceFirst("First coommit", "First commit"),
)
composeRule.onNodeWithTag("git-message-editor-save").performClick()
}
private fun startRenameCommitReword() {
submitTerminalCommand("git rebase -i HEAD~2")
waitForGitEditorPath(".git/rebase-merge/git-rebase-todo")
replaceGitEditorContent(
currentGitEditorContent().replaceFirst(Regex("(?m)^pick "), "reword "),
)
composeRule.onNodeWithTag("git-message-editor-save").performClick()
}
private fun waitForGitEditorPath(path: String) {
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodes(hasText(path, substring = true)).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("git-message-editor-path").assertIsDisplayed()
}
private fun currentGitEditorContent(): String {
val node = composeRule.onNodeWithTag("git-message-editor-content").fetchSemanticsNode()
return node.config.getOrNull(SemanticsProperties.EditableText)?.text
?: error("Expected Git editor content semantics")
}
private fun replaceGitEditorContent(content: String) {
composeRule.onNodeWithTag("git-message-editor-content")
.performSemanticsAction(SemanticsActions.SetText) { setText ->
setText(AnnotatedString(content))
}
}
private fun submitTerminalCommand(command: String) { private fun submitTerminalCommand(command: String) {
val input = composeRule.onNodeWithTag("terminal-command-input") val input = composeRule.onNodeWithTag("terminal-command-input")
input.performClick() input.performClick()

View File

@@ -11,6 +11,7 @@ data class GitEditorInvocation(
val command: String, val command: String,
val kind: GitEditorCommandKind, val kind: GitEditorCommandKind,
val title: String, val title: String,
val displayPath: String,
val initialContent: String = "", val initialContent: String = "",
) )
@@ -35,6 +36,7 @@ private fun parseGitCommitEditor(command: String, arguments: List<String>): GitE
command = command, command = command,
kind = GitEditorCommandKind.COMMIT_MESSAGE, kind = GitEditorCommandKind.COMMIT_MESSAGE,
title = "Edit Commit Message", title = "Edit Commit Message",
displayPath = ".git/COMMIT_EDITMSG",
) )
} }
@@ -48,6 +50,7 @@ private fun parseGitRebaseEditor(command: String, arguments: List<String>): GitE
command = command, command = command,
kind = GitEditorCommandKind.REBASE_TODO, kind = GitEditorCommandKind.REBASE_TODO,
title = "Edit Rebase Todo", title = "Edit Rebase Todo",
displayPath = ".git/rebase-merge/git-rebase-todo",
) )
} }
@@ -61,6 +64,7 @@ private fun parseGitTagEditor(command: String, arguments: List<String>): GitEdit
command = command, command = command,
kind = GitEditorCommandKind.TAG_MESSAGE, kind = GitEditorCommandKind.TAG_MESSAGE,
title = "Edit Tag Message", title = "Edit Tag Message",
displayPath = ".git/TAG_EDITMSG",
) )
} }

View File

@@ -2,6 +2,17 @@ package solutions.tretter.githugandroid
import java.io.File import java.io.File
data class GitEditorContinuation(
val invocation: GitEditorInvocation,
val content: String,
)
data class GitEditorExecutionResult(
val repo: RepoState,
val outputLines: List<String>,
val nextEditor: GitEditorContinuation? = null,
)
internal class GitEditorWorkflow( internal class GitEditorWorkflow(
private val requireNativeGit: () -> File, private val requireNativeGit: () -> File,
private val prepareLevel: (Level) -> RepoState, private val prepareLevel: (Level) -> RepoState,
@@ -57,7 +68,7 @@ internal class GitEditorWorkflow(
currentRepo: RepoState, currentRepo: RepoState,
invocation: GitEditorInvocation, invocation: GitEditorInvocation,
message: String, message: String,
): Pair<RepoState, List<String>> { ): GitEditorExecutionResult {
val nativeGit = requireNativeGit() val nativeGit = requireNativeGit()
val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo) val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo)
return when (invocation.kind) { return when (invocation.kind) {
@@ -83,7 +94,7 @@ internal class GitEditorWorkflow(
) )
GitEditorCommandKind.PATCH_HUNK -> { GitEditorCommandKind.PATCH_HUNK -> {
currentRepo to listOf("Patch hunk editing is handled by Git, not the Android runtime.") GitEditorExecutionResult(currentRepo, listOf("Patch hunk editing is handled by Git, not the Android runtime."))
} }
} }
} }
@@ -96,19 +107,39 @@ internal class GitEditorWorkflow(
workingDir: File, workingDir: File,
invocation: GitEditorInvocation, invocation: GitEditorInvocation,
message: String, message: String,
): Pair<RepoState, List<String>> { ): GitEditorExecutionResult {
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)
} }
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command) val commandTokens = GitSandboxEngine.tokenizeCommand(invocation.command)
val arguments = commandTokens
.drop(1) .drop(1)
.toMutableList() .toMutableList()
.apply { addAll(listOf("-F", messageFile.absolutePath)) } .apply { addAll(listOf("-F", messageFile.absolutePath)) }
val result = runGit(nativeGit, workingDir, arguments, emptyMap()) val result = runGit(nativeGit, workingDir, arguments, emptyMap())
messageFile.delete() messageFile.delete()
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines if (
result.exitCode == 0 &&
invocation.kind == GitEditorCommandKind.COMMIT_MESSAGE &&
commandTokens.drop(1).isCommitAmendCommand() &&
rebaseStateExists(File(sandboxRoot, ".git"))
) {
return runRebaseContinueCapturingEditor(
level = level,
currentRepo = currentRepo,
nativeGit = nativeGit,
sandboxRoot = sandboxRoot,
workingDir = workingDir,
leadingOutput = result.outputLines,
)
}
return GitEditorExecutionResult(
repo = inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir),
outputLines = result.outputLines,
)
} }
private fun executeInteractiveRebase( private fun executeInteractiveRebase(
@@ -119,7 +150,7 @@ internal class GitEditorWorkflow(
workingDir: File, workingDir: File,
invocation: GitEditorInvocation, invocation: GitEditorInvocation,
todo: String, todo: String,
): Pair<RepoState, List<String>> { ): GitEditorExecutionResult {
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() } val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply { val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
writeText(todo) writeText(todo)
@@ -132,19 +163,106 @@ internal class GitEditorWorkflow(
|""".trimMargin(), |""".trimMargin(),
) )
} }
val capture = editorCaptureFiles(gitDir)
capture.content.delete()
capture.path.delete()
val messageEditorScript = createCaptureEditorScript(gitDir, capture)
val result = runGit( val result = runGit(
nativeGit, nativeGit,
workingDir, workingDir,
GitSandboxEngine.tokenizeCommand(invocation.command).drop(1), GitSandboxEngine.tokenizeCommand(invocation.command).drop(1),
mapOf( mapOf(
"GIT_SEQUENCE_EDITOR" to editorCommand(editorScript), "GIT_SEQUENCE_EDITOR" to editorCommand(editorScript),
"GIT_EDITOR" to "true", "GIT_EDITOR" to editorCommand(messageEditorScript),
), ),
) )
todoFile.delete() todoFile.delete()
editorScript.delete() editorScript.delete()
messageEditorScript.delete()
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines val nextEditor = capturedCommitMessageEditor(sandboxRoot, capture)
capture.content.delete()
capture.path.delete()
return GitEditorExecutionResult(
repo = inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir),
outputLines = result.outputLines,
nextEditor = nextEditor,
)
}
private fun runRebaseContinueCapturingEditor(
level: Level,
currentRepo: RepoState,
nativeGit: File,
sandboxRoot: File,
workingDir: File,
leadingOutput: List<String>,
): GitEditorExecutionResult {
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
val capture = editorCaptureFiles(gitDir)
capture.content.delete()
capture.path.delete()
val messageEditorScript = createCaptureEditorScript(gitDir, capture)
val result = runGit(
nativeGit,
workingDir,
listOf("rebase", "--continue"),
mapOf("GIT_EDITOR" to editorCommand(messageEditorScript)),
)
messageEditorScript.delete()
val nextEditor = capturedCommitMessageEditor(sandboxRoot, capture)
capture.content.delete()
capture.path.delete()
return GitEditorExecutionResult(
repo = inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir),
outputLines = leadingOutput + result.outputLines,
nextEditor = nextEditor,
)
}
private data class EditorCaptureFiles(
val content: File,
val path: File,
)
private fun editorCaptureFiles(gitDir: File): EditorCaptureFiles = EditorCaptureFiles(
content = File(gitDir, "GITHUG_ANDROID_CAPTURED_EDITOR"),
path = File(gitDir, "GITHUG_ANDROID_CAPTURED_EDITOR_PATH"),
)
private fun createCaptureEditorScript(gitDir: File, capture: EditorCaptureFiles): File {
return File(gitDir, "githug-android-capture-message-editor.sh").apply {
writeText(
"""
|#!/bin/sh
|cat "$1" > ${capture.content.absolutePath.toShellSingleQuoted()}
|printf '%s\n' "$1" > ${capture.path.absolutePath.toShellSingleQuoted()}
|exit 1
|""".trimMargin(),
)
}
}
private fun capturedCommitMessageEditor(sandboxRoot: File, capture: EditorCaptureFiles): GitEditorContinuation? {
val content = capture.content.takeIf { it.isFile }?.readText() ?: return null
val capturedPath = capture.path.takeIf { it.isFile }?.readText()?.trim().orEmpty()
val displayPath = capturedPath
.takeIf { it.isNotBlank() }
?.let { File(it).relativeToOrSelf(sandboxRoot).path }
?: ".git/COMMIT_EDITMSG"
return GitEditorContinuation(
invocation = GitEditorInvocation(
command = "git commit --amend",
kind = GitEditorCommandKind.COMMIT_MESSAGE,
title = "Edit Commit Message",
displayPath = displayPath,
initialContent = content,
),
content = content,
)
} }
private fun repositoryPaths(level: Level, currentRepo: RepoState): Pair<File, File> { private fun repositoryPaths(level: Level, currentRepo: RepoState): Pair<File, File> {
@@ -166,6 +284,10 @@ internal class GitEditorWorkflow(
return File(gitDir, "rebase-merge").exists() || File(gitDir, "rebase-apply").exists() return File(gitDir, "rebase-merge").exists() || File(gitDir, "rebase-apply").exists()
} }
private fun List<String>.isCommitAmendCommand(): Boolean {
return firstOrNull() == "commit" && any { it == "--amend" }
}
private fun String.toShellSingleQuoted(): String { private fun String.toShellSingleQuoted(): String {
return "'" + replace("'", "'\"'\"'") + "'" return "'" + replace("'", "'\"'\"'") + "'"
} }

View File

@@ -513,14 +513,20 @@ fun GitHugApp() {
fun saveGitMessageEditor() { fun saveGitMessageEditor() {
val state = gitMessageEditorState ?: return val state = gitMessageEditorState ?: return
val (newRepo, lines) = runtime.executeGitEditorCommand( val result = runtime.executeGitEditorCommandWithResult(
level = currentLevel, level = currentLevel,
currentRepo = repo, currentRepo = repo,
invocation = state.invocation, invocation = state.invocation,
message = state.content, message = state.content,
) )
gitMessageEditorState = null gitMessageEditorState = null
applyCommandResult(state.invocation.command, newRepo, lines, echoCommand = false) applyCommandResult(state.invocation.command, result.repo, result.outputLines, echoCommand = false)
result.nextEditor?.let { nextEditor ->
gitMessageEditorState = GitMessageEditorState(
invocation = nextEditor.invocation,
content = nextEditor.content,
)
}
} }
fun runCommand() { fun runCommand() {

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -58,7 +59,7 @@ fun GitMessageEditorDialog(
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(state.invocation.command) { LaunchedEffect(state.invocation.command) {
contentFocusRequester.requestFocus() runCatching { contentFocusRequester.requestFocus() }
keyboardController?.show() keyboardController?.show()
} }
@@ -86,6 +87,7 @@ fun GitMessageEditorDialog(
) { ) {
Text( Text(
text = state.invocation.title, text = state.invocation.title,
modifier = Modifier.testTag("git-message-editor-title"),
color = TextPrimary, color = TextPrimary,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 20.sp, fontSize = 20.sp,
@@ -94,11 +96,21 @@ fun GitMessageEditorDialog(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
EditorButton(label = "Save", enabled = state.content.isNotBlank(), onClick = onSave) EditorButton(
EditorButton(label = "Dismiss", onClick = onClose) label = "Save",
enabled = state.content.isNotBlank(),
modifier = Modifier.testTag("git-message-editor-save"),
onClick = onSave,
)
EditorButton(
label = "Dismiss",
modifier = Modifier.testTag("git-message-editor-dismiss"),
onClick = onClose,
)
} }
Text( Text(
text = state.invocation.command, text = state.invocation.displayPath,
modifier = Modifier.testTag("git-message-editor-path"),
color = TextSecondary, color = TextSecondary,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 13.sp, fontSize = 13.sp,
@@ -118,7 +130,8 @@ fun GitMessageEditorDialog(
onValueChange = onContentChange, onValueChange = onContentChange,
modifier = Modifier modifier = Modifier
.sizeIn(minWidth = 1200.dp, minHeight = 1200.dp) .sizeIn(minWidth = 1200.dp, minHeight = 1200.dp)
.focusRequester(contentFocusRequester), .focusRequester(contentFocusRequester)
.testTag("git-message-editor-content"),
textStyle = TextStyle( textStyle = TextStyle(
color = TextPrimary, color = TextPrimary,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
@@ -141,11 +154,13 @@ fun GitMessageEditorDialog(
private fun EditorButton( private fun EditorButton(
label: String, label: String,
enabled: Boolean = true, enabled: Boolean = true,
modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Button( Button(
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
modifier = modifier,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = Accent, containerColor = Accent,
contentColor = AppBackground, contentColor = AppBackground,

View File

@@ -265,6 +265,16 @@ class GitRepositoryRuntime private constructor(
invocation: GitEditorInvocation, invocation: GitEditorInvocation,
message: String, message: String,
): Pair<RepoState, List<String>> { ): Pair<RepoState, List<String>> {
val result = executeGitEditorCommandWithResult(level, currentRepo, invocation, message)
return result.repo to result.outputLines
}
fun executeGitEditorCommandWithResult(
level: Level,
currentRepo: RepoState,
invocation: GitEditorInvocation,
message: String,
): GitEditorExecutionResult {
return editorWorkflow.execute(level, currentRepo, invocation, message) return editorWorkflow.execute(level, currentRepo, invocation, message)
} }

View File

@@ -51,6 +51,7 @@ internal object InteractiveAddEngine {
command = command, command = command,
kind = GitEditorCommandKind.PATCH_HUNK, kind = GitEditorCommandKind.PATCH_HUNK,
title = "Edit Patch Hunk", title = "Edit Patch Hunk",
displayPath = target,
initialContent = editablePatchHunkContent(file), initialContent = editablePatchHunkContent(file),
) )
} }

View File

@@ -65,7 +65,7 @@ fun TextEditorDialog(
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(state.originalCommand, state.path) { LaunchedEffect(state.originalCommand, state.path) {
contentFocusRequester.requestFocus() runCatching { contentFocusRequester.requestFocus() }
keyboardController?.show() keyboardController?.show()
} }

View File

@@ -18,5 +18,7 @@ internal fun addLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("add exact file", "git add README"), levelTestCase("add exact file", "git add README"),
levelTestCase("add all", "git add ."), levelTestCase("add all", "git add ."),
levelTestCase("stage exact file", "git stage README"),
levelTestCase("add with path separator", "git add -- README"),
), ),
) )

View File

@@ -91,6 +91,14 @@ internal fun bisectLevel(): Level = level(
"git bisect run ./test-balance.sh", "git bisect run ./test-balance.sh",
"c8c7c00", "c8c7c00",
), ),
levelTestCase(
"answer last good commit after shell run",
"git bisect start",
"git bisect bad HEAD",
"git bisect good known-good",
"git bisect run sh test-balance.sh",
"c8c7",
),
), ),
negativeTestCases = listOf( negativeTestCases = listOf(
levelTestCase( levelTestCase(

View File

@@ -28,5 +28,6 @@ internal fun branchAtLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("branch at previous commit", "git branch test_branch HEAD~1"), levelTestCase("branch at previous commit", "git branch test_branch HEAD~1"),
levelTestCase("branch at caret", "git branch test_branch HEAD^"), levelTestCase("branch at caret", "git branch test_branch HEAD^"),
levelTestCase("branch at explicit master parent", "git branch test_branch master^"),
), ),
) )

View File

@@ -17,5 +17,6 @@ internal fun branchLevel(): Level = level(
validator = repoPredicate { repo -> "test_code" in repo.branches && repo.headBranch == "master" }, validator = repoPredicate { repo -> "test_code" in repo.branches && repo.headBranch == "master" },
testCases = listOf( testCases = listOf(
levelTestCase("create branch", "git branch test_code"), levelTestCase("create branch", "git branch test_code"),
levelTestCase("create branch from master", "git branch test_code master"),
), ),
) )

View File

@@ -24,5 +24,7 @@ internal fun checkoutFileLevel(): Level = level(
validator = repoPredicate { repo -> repo.files.find { it.name == "config.rb" }?.content == "This is the initial config file" }, validator = repoPredicate { repo -> repo.files.find { it.name == "config.rb" }?.content == "This is the initial config file" },
testCases = listOf( testCases = listOf(
levelTestCase("checkout file from head", "git checkout -- config.rb"), levelTestCase("checkout file from head", "git checkout -- config.rb"),
levelTestCase("restore file from index", "git restore config.rb"),
levelTestCase("checkout file from explicit head", "git checkout HEAD -- config.rb"),
), ),
) )

View File

@@ -17,5 +17,7 @@ internal fun checkoutLevel(): Level = level(
validator = repoPredicate { repo -> repo.headBranch == "my_branch" && "my_branch" in repo.branches }, validator = repoPredicate { repo -> repo.headBranch == "my_branch" && "my_branch" in repo.branches },
testCases = listOf( testCases = listOf(
levelTestCase("checkout new branch", "git checkout -b my_branch"), levelTestCase("checkout new branch", "git checkout -b my_branch"),
levelTestCase("switch create branch", "git switch -c my_branch"),
levelTestCase("branch then checkout", "git branch my_branch", "git checkout my_branch"),
), ),
) )

View File

@@ -22,6 +22,7 @@ internal fun checkoutTagLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("checkout tag", "git checkout v1.2"), levelTestCase("checkout tag", "git checkout v1.2"),
levelTestCase("checkout explicit tag", "git checkout tags/v1.2"), levelTestCase("checkout explicit tag", "git checkout tags/v1.2"),
levelTestCase("switch detached tag", "git switch --detach v1.2"),
), ),
) )

View File

@@ -26,5 +26,6 @@ internal fun checkoutTagOverBranchLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("checkout tag namespace", "git checkout tags/v1.2"), levelTestCase("checkout tag namespace", "git checkout tags/v1.2"),
levelTestCase("checkout refs tag", "git checkout refs/tags/v1.2"), levelTestCase("checkout refs tag", "git checkout refs/tags/v1.2"),
levelTestCase("switch detached tag namespace", "git switch --detach tags/v1.2"),
), ),
) )

View File

@@ -17,5 +17,7 @@ internal fun commitAmendLevel(): Level = level(
validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "forgotten_file.rb" && it.tracked && !it.staged } }, validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "forgotten_file.rb" && it.tracked && !it.staged } },
testCases = listOf( testCases = listOf(
levelTestCase("amend after add", "git add forgotten_file.rb", "git commit --amend --no-edit"), levelTestCase("amend after add", "git add forgotten_file.rb", "git commit --amend --no-edit"),
levelTestCase("amend with message option", "git add forgotten_file.rb", "git commit --amend -m \"Initial commit\""),
levelTestCase("amend through editor", "git add forgotten_file.rb", "GIT_EDITOR=true git commit --amend"),
), ),
) )

View File

@@ -22,6 +22,7 @@ internal fun commitInFutureLevel(): Level = level(
}, },
testCases = listOf( testCases = listOf(
levelTestCase("commit with date option", "git commit --date 2037-01-01T00:00:00+0000 -m \"Future commit\""), levelTestCase("commit with date option", "git commit --date 2037-01-01T00:00:00+0000 -m \"Future commit\""),
levelTestCase("commit with author date environment", "GIT_AUTHOR_DATE=2037-01-01T00:00:00+0000 git commit -m \"Future commit\""),
), ),
negativeTestCases = listOf( negativeTestCases = listOf(
levelTestCase("current date commit does not solve", "git commit -m \"Current date commit\""), levelTestCase("current date commit does not solve", "git commit -m \"Current date commit\""),

View File

@@ -18,5 +18,6 @@ internal fun commitLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("commit with message", "git commit -m \"Initial commit\""), levelTestCase("commit with message", "git commit -m \"Initial commit\""),
levelTestCase("commit with alternate message", "git commit -m \"Add README\""), levelTestCase("commit with alternate message", "git commit -m \"Add README\""),
levelTestCase("commit through editor", "GIT_EDITOR=\"sed -i '1iInitial commit'\" git commit"),
), ),
) )

View File

@@ -18,5 +18,6 @@ internal fun configLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("name then email", "git config user.name GitHug", "git config user.email githug@example.com"), levelTestCase("name then email", "git config user.name GitHug", "git config user.email githug@example.com"),
levelTestCase("email then name", "git config user.email githug@example.com", "git config user.name GitHug"), levelTestCase("email then name", "git config user.email githug@example.com", "git config user.name GitHug"),
levelTestCase("explicit local config", "git config --local user.name GitHug", "git config --local user.email githug@example.com"),
), ),
) )

View File

@@ -49,5 +49,6 @@ internal fun fetchLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("fetch origin", "git fetch origin"), levelTestCase("fetch origin", "git fetch origin"),
levelTestCase("fetch default", "git fetch"), levelTestCase("fetch default", "git fetch"),
levelTestCase("fetch all remotes", "git fetch --all"),
), ),
) )

View File

@@ -46,6 +46,7 @@ internal fun findOldBranchLevel(): Level = level(
validator = repoPredicate { repo -> repo.headBranch == "solve_world_hunger" }, validator = repoPredicate { repo -> repo.headBranch == "solve_world_hunger" },
testCases = listOf( testCases = listOf(
levelTestCase("checkout old branch", "git checkout solve_world_hunger"), levelTestCase("checkout old branch", "git checkout solve_world_hunger"),
levelTestCase("switch old branch", "git switch solve_world_hunger"),
), ),
setupChecks = listOf( setupChecks = listOf(
levelSetupCheck( levelSetupCheck(

View File

@@ -17,6 +17,7 @@ internal fun initLevel(): Level = level(
validator = repoPredicate { it.initialized }, validator = repoPredicate { it.initialized },
testCases = listOf( testCases = listOf(
levelTestCase("plain init", "git init"), levelTestCase("plain init", "git init"),
levelTestCase("init current directory explicitly", "git init ."),
), ),
setupChecks = listOf( setupChecks = listOf(
levelSetupCheck( levelSetupCheck(

View File

@@ -30,6 +30,7 @@ internal fun mergeLevel(): Level = level(
}, },
testCases = listOf( testCases = listOf(
levelTestCase("merge feature", "git merge feature"), levelTestCase("merge feature", "git merge feature"),
levelTestCase("merge feature with explicit merge commit", "git merge --no-ff feature -m \"Merge feature\""),
), ),
negativeTestCases = listOf( negativeTestCases = listOf(
levelTestCase("switching to feature does not solve", "git switch feature"), levelTestCase("switching to feature does not solve", "git switch feature"),

View File

@@ -37,5 +37,6 @@ internal fun mergeSquashLevel(): Level = level(
}, },
testCases = listOf( testCases = listOf(
levelTestCase("squash merge then commit", "git merge --squash long-feature-branch", "git commit -m \"Merge long feature\""), levelTestCase("squash merge then commit", "git merge --squash long-feature-branch", "git commit -m \"Merge long feature\""),
levelTestCase("squash no commit then commit", "git merge --squash --no-commit long-feature-branch", "git commit -m \"Merge long feature\""),
), ),
) )

View File

@@ -40,5 +40,6 @@ internal fun pullLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("pull explicit remote branch", "git pull origin master"), levelTestCase("pull explicit remote branch", "git pull origin master"),
levelTestCase("pull default origin", "git pull"), levelTestCase("pull default origin", "git pull"),
levelTestCase("fetch then merge", "git fetch origin", "git merge origin/master"),
), ),
) )

View File

@@ -45,5 +45,6 @@ internal fun pushBranchLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("push named branch", "git push origin test_branch"), levelTestCase("push named branch", "git push origin test_branch"),
levelTestCase("push current branch refspec", "git push origin test_branch:test_branch"), levelTestCase("push current branch refspec", "git push origin test_branch:test_branch"),
levelTestCase("push fully qualified branch refspec", "git push origin refs/heads/test_branch:refs/heads/test_branch"),
), ),
) )

View File

@@ -86,5 +86,6 @@ internal fun pushLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("pull rebase then push", "git pull --rebase origin master", "git push origin master"), levelTestCase("pull rebase then push", "git pull --rebase origin master", "git push origin master"),
levelTestCase("fetch rebase push", "git fetch origin", "git rebase origin/master", "git push origin master"), levelTestCase("fetch rebase push", "git fetch origin", "git rebase origin/master", "git push origin master"),
levelTestCase("fetch rebase upstream push", "git fetch", "git rebase origin/master", "git push"),
), ),
) )

View File

@@ -35,5 +35,6 @@ internal fun pushTagsLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("push all tags", "git push --tags"), levelTestCase("push all tags", "git push --tags"),
levelTestCase("push tags to origin", "git push origin --tags"), levelTestCase("push tags to origin", "git push origin --tags"),
levelTestCase("push named tag", "git push origin tag tag_to_be_pushed"),
), ),
) )

View File

@@ -33,5 +33,6 @@ internal fun rebaseLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("checkout feature then rebase", "git checkout feature", "git rebase master"), levelTestCase("checkout feature then rebase", "git checkout feature", "git rebase master"),
levelTestCase("rebase named branch", "git rebase master feature"), levelTestCase("rebase named branch", "git rebase master feature"),
levelTestCase("switch feature then rebase", "git switch feature", "git rebase master"),
), ),
) )

View File

@@ -44,5 +44,6 @@ internal fun rebaseOntoLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("rebase onto explicit branch", "git rebase --onto master wrong_branch readme-update"), levelTestCase("rebase onto explicit branch", "git rebase --onto master wrong_branch readme-update"),
levelTestCase("rebase onto current branch", "git rebase --onto master wrong_branch"), levelTestCase("rebase onto current branch", "git rebase --onto master wrong_branch"),
levelTestCase("rebase onto full branch ref", "git rebase --onto refs/heads/master wrong_branch readme-update"),
), ),
) )

View File

@@ -17,5 +17,6 @@ internal fun remoteAddLevel(): Level = level(
validator = repoPredicate { repo -> repo.remotes["origin"] == "https://github.com/githug/githug" }, validator = repoPredicate { repo -> repo.remotes["origin"] == "https://github.com/githug/githug" },
testCases = listOf( testCases = listOf(
levelTestCase("add origin remote", "git remote add origin https://github.com/githug/githug"), levelTestCase("add origin remote", "git remote add origin https://github.com/githug/githug"),
levelTestCase("add origin remote with tracked branch option", "git remote add -t master origin https://github.com/githug/githug"),
), ),
) )

View File

@@ -30,5 +30,17 @@ internal fun renameCommitLevel(): Level = level(
"interactive rebase rename", "interactive rebase rename",
"GIT_SEQUENCE_EDITOR=\"sed -i '1s/^pick /reword /'\" GIT_EDITOR=\"sed -i '1s/First coommit/First commit/'\" git rebase -i HEAD~2", "GIT_SEQUENCE_EDITOR=\"sed -i '1s/^pick /reword /'\" GIT_EDITOR=\"sed -i '1s/First coommit/First commit/'\" git rebase -i HEAD~2",
), ),
levelTestCase(
"interactive rebase then amend message option",
"GIT_SEQUENCE_EDITOR=\"sed -i '1s/^pick /reword /'\" GIT_EDITOR=false git rebase -i HEAD~2",
"git commit --amend -m \"First commit\"",
"git rebase --continue",
),
levelTestCase(
"interactive rebase then amend editor",
"GIT_SEQUENCE_EDITOR=\"sed -i '1s/^pick /reword /'\" GIT_EDITOR=false git rebase -i HEAD~2",
"GIT_EDITOR=\"sed -i '1s/First coommit/First commit/'\" git commit --amend",
"git rebase --continue",
),
), ),
) )

View File

@@ -17,5 +17,6 @@ internal fun renameLevel(): Level = level(
validator = repoPredicate { repo -> repo.files.any { it.name == "newfile.txt" } && repo.files.none { it.name == "oldfile.txt" && !it.deleted } }, validator = repoPredicate { repo -> repo.files.any { it.name == "newfile.txt" } && repo.files.none { it.name == "oldfile.txt" && !it.deleted } },
testCases = listOf( testCases = listOf(
levelTestCase("git mv", "git mv oldfile.txt newfile.txt"), levelTestCase("git mv", "git mv oldfile.txt newfile.txt"),
levelTestCase("git mv force", "git mv -f oldfile.txt newfile.txt"),
), ),
) )

View File

@@ -26,5 +26,6 @@ internal fun resetLevel(): Level = level(
validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "to_commit_first.rb" && it.staged } && repo.files.any { it.name == "to_commit_second.rb" && !it.staged } }, validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "to_commit_first.rb" && it.staged } && repo.files.any { it.name == "to_commit_second.rb" && !it.staged } },
testCases = listOf( testCases = listOf(
levelTestCase("reset path", "git reset to_commit_second.rb"), levelTestCase("reset path", "git reset to_commit_second.rb"),
levelTestCase("restore staged path", "git restore --staged to_commit_second.rb"),
), ),
) )

View File

@@ -25,5 +25,6 @@ internal fun resetSoftLevel(): Level = level(
validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "newfile.rb" && it.staged } }, validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "newfile.rb" && it.staged } },
testCases = listOf( testCases = listOf(
levelTestCase("soft reset caret", "git reset --soft HEAD^"), levelTestCase("soft reset caret", "git reset --soft HEAD^"),
levelTestCase("soft reset previous commit", "git reset --soft HEAD~1"),
), ),
) )

View File

@@ -28,5 +28,6 @@ internal fun restoreLevel(): Level = level(
validator = repoPredicate { repo -> repo.files.any { it.name == "file3" && it.tracked } }, validator = repoPredicate { repo -> repo.files.any { it.name == "file3" && it.tracked } },
testCases = listOf( testCases = listOf(
levelTestCase("checkout file from reflog commit", "git checkout HEAD@{1} -- file3"), levelTestCase("checkout file from reflog commit", "git checkout HEAD@{1} -- file3"),
levelTestCase("restore file from reflog commit", "git restore --source=HEAD@{1} --staged --worktree file3"),
), ),
) )

View File

@@ -18,5 +18,6 @@ internal fun restructureLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("move one by one", "mkdir src", "git mv about.html src/about.html", "git mv contact.html src/contact.html", "git mv index.html src/index.html"), levelTestCase("move one by one", "mkdir src", "git mv about.html src/about.html", "git mv contact.html src/contact.html", "git mv index.html src/index.html"),
levelTestCase("move with wildcard", "mkdir src", "git mv *.html src"), levelTestCase("move with wildcard", "mkdir src", "git mv *.html src"),
levelTestCase("move with wildcard into slash directory", "mkdir src", "git mv *.html src/"),
), ),
) )

View File

@@ -27,5 +27,7 @@ internal fun revertLevel(): Level = level(
validator = repoPredicate { repo -> repo.commits.any { it.message.startsWith("Revert") } }, validator = repoPredicate { repo -> repo.commits.any { it.message.startsWith("Revert") } },
testCases = listOf( testCases = listOf(
levelTestCase("revert middle commit", "git revert HEAD~1"), levelTestCase("revert middle commit", "git revert HEAD~1"),
levelTestCase("revert middle commit without editor", "git revert --no-edit HEAD~1"),
levelTestCase("revert caret commit", "git revert HEAD^"),
), ),
) )

View File

@@ -17,5 +17,6 @@ internal fun rmCachedLevel(): Level = level(
validator = repoPredicate { repo -> repo.files.any { it.name == "deleteme.rb" && !it.staged && !it.tracked && !it.deleted } }, validator = repoPredicate { repo -> repo.files.any { it.name == "deleteme.rb" && !it.staged && !it.tracked && !it.deleted } },
testCases = listOf( testCases = listOf(
levelTestCase("rm cached", "git rm --cached deleteme.rb"), levelTestCase("rm cached", "git rm --cached deleteme.rb"),
levelTestCase("reset staged path", "git reset deleteme.rb"),
), ),
) )

View File

@@ -20,5 +20,6 @@ internal fun rmLevel(): Level = level(
}, },
testCases = listOf( testCases = listOf(
levelTestCase("git rm deleted path", "git rm deleteme.rb"), levelTestCase("git rm deleted path", "git rm deleteme.rb"),
levelTestCase("stage deleted path", "git add -u deleteme.rb"),
), ),
) )

View File

@@ -39,6 +39,12 @@ internal fun squashLevel(): Level = level(
"git reset --soft HEAD~4", "git reset --soft HEAD~4",
"git commit -m \"New commit message\"", "git commit -m \"New commit message\"",
), ),
levelTestCase(
"mixed reset add and recommit",
"git reset HEAD~4",
"git add README",
"git commit -m \"New commit message\"",
),
), ),
negativeTestCases = listOf( negativeTestCases = listOf(
levelTestCase("reset without replacement commit", "git reset --soft HEAD~4"), levelTestCase("reset without replacement commit", "git reset --soft HEAD~4"),

View File

@@ -24,5 +24,6 @@ internal fun stageLinesLevel(): Level = level(
validator = repoPredicate { repo -> repo.files.any { it.name == "feature.rb" && it.staged } }, validator = repoPredicate { repo -> repo.files.any { it.name == "feature.rb" && it.staged } },
testCases = listOf( testCases = listOf(
levelTestCase("stage feature file", "git add feature.rb"), levelTestCase("stage feature file", "git add feature.rb"),
levelTestCase("stage alias", "git stage feature.rb"),
), ),
) )

View File

@@ -24,6 +24,8 @@ internal fun stashLevel(): Level = level(
validator = repoPredicate { repo -> repo.stashes.isNotEmpty() && repo.files.none { it.staged } }, validator = repoPredicate { repo -> repo.stashes.isNotEmpty() && repo.files.none { it.staged } },
testCases = listOf( testCases = listOf(
levelTestCase("stash changes", "git stash"), levelTestCase("stash changes", "git stash"),
levelTestCase("stash push", "git stash push"),
levelTestCase("stash with message", "git stash push -m \"save lyrics\""),
), ),
setupChecks = listOf( setupChecks = listOf(
levelSetupCheck( levelSetupCheck(

View File

@@ -40,5 +40,11 @@ internal fun submoduleLevel(): Level = level(
"git config -f .gitmodules submodule.githug-include-me.url ../submodule-source", "git config -f .gitmodules submodule.githug-include-me.url ../submodule-source",
"git add .gitmodules", "git add .gitmodules",
), ),
levelTestCase(
"record submodule metadata url first",
"git config -f .gitmodules submodule.githug-include-me.url ../submodule-source",
"git config -f .gitmodules submodule.githug-include-me.path githug-include-me",
"git add .gitmodules",
),
), ),
) )

View File

@@ -17,5 +17,7 @@ internal fun tagLevel(): Level = level(
validator = repoPredicate { repo -> "new_tag" in repo.tags }, validator = repoPredicate { repo -> "new_tag" in repo.tags },
testCases = listOf( testCases = listOf(
levelTestCase("create tag", "git tag new_tag"), levelTestCase("create tag", "git tag new_tag"),
levelTestCase("create tag at head", "git tag new_tag HEAD"),
levelTestCase("create annotated tag", "git tag -a new_tag -m \"New tag\""),
), ),
) )

View File

@@ -11,6 +11,7 @@ class GitEditorCommandsTest {
assertEquals(GitEditorCommandKind.COMMIT_MESSAGE, invocation?.kind) assertEquals(GitEditorCommandKind.COMMIT_MESSAGE, invocation?.kind)
assertEquals("Edit Commit Message", invocation?.title) assertEquals("Edit Commit Message", invocation?.title)
assertEquals(".git/COMMIT_EDITMSG", invocation?.displayPath)
} }
@Test @Test
@@ -34,6 +35,7 @@ class GitEditorCommandsTest {
assertEquals(GitEditorCommandKind.REBASE_TODO, invocation?.kind) assertEquals(GitEditorCommandKind.REBASE_TODO, invocation?.kind)
assertEquals("Edit Rebase Todo", invocation?.title) assertEquals("Edit Rebase Todo", invocation?.title)
assertEquals(".git/rebase-merge/git-rebase-todo", invocation?.displayPath)
} }
@Test @Test
@@ -47,6 +49,7 @@ class GitEditorCommandsTest {
assertEquals(GitEditorCommandKind.TAG_MESSAGE, invocation?.kind) assertEquals(GitEditorCommandKind.TAG_MESSAGE, invocation?.kind)
assertEquals("Edit Tag Message", invocation?.title) assertEquals("Edit Tag Message", invocation?.title)
assertEquals(".git/TAG_EDITMSG", invocation?.displayPath)
} }
@Test @Test

View File

@@ -2,6 +2,8 @@ package solutions.tretter.githugandroid
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue import org.junit.Assume.assumeTrue
import org.junit.Test import org.junit.Test
@@ -451,6 +453,39 @@ class GitSandboxEngineTest {
} }
} }
@Test
fun nativeInteractiveRebaseRewordRequestsCommitMessageEditorAndContinues() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-interactive-rebase-reword-editor").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = renameCommitLevel()
val repo = runtime.prepareLevel(level)
val invocation = parseGitEditorInvocation("git rebase -i HEAD~2")
?: error("Expected interactive rebase editor invocation")
val todo = runtime.gitEditorInitialContent(level, repo, invocation)
val rewordTodo = todo.replaceFirst(Regex("(?m)^pick "), "reword ")
val rebaseResult = runtime.executeGitEditorCommandWithResult(level, repo, invocation, rewordTodo)
val nextEditor = rebaseResult.nextEditor
assertNotNull(nextEditor)
val message = nextEditor!!.content.replaceFirst("First coommit", "First commit")
val completedResult = runtime.executeGitEditorCommandWithResult(
level = level,
currentRepo = rebaseResult.repo,
invocation = nextEditor.invocation,
message = message,
)
assertEquals(".git/COMMIT_EDITMSG", nextEditor.invocation.displayPath)
assertNull(completedResult.nextEditor)
assertTrue(level.validator(completedResult.repo, nextEditor.invocation.command))
} finally {
root.deleteRecursively()
}
}
@Test @Test
fun nativeExecutableShortcutRunsScriptThroughShell() { fun nativeExecutableShortcutRunsScriptThroughShell() {
val git = testGitBinary() val git = testGitBinary()