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:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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("'", "'\"'\"'") + "'"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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^"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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\""),
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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\""),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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^"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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\""),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user