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

@@ -11,6 +11,7 @@ data class GitEditorInvocation(
val command: String,
val kind: GitEditorCommandKind,
val title: String,
val displayPath: String,
val initialContent: String = "",
)
@@ -35,6 +36,7 @@ private fun parseGitCommitEditor(command: String, arguments: List<String>): GitE
command = command,
kind = GitEditorCommandKind.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,
kind = GitEditorCommandKind.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,
kind = GitEditorCommandKind.TAG_MESSAGE,
title = "Edit Tag Message",
displayPath = ".git/TAG_EDITMSG",
)
}

View File

@@ -2,6 +2,17 @@ package solutions.tretter.githugandroid
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(
private val requireNativeGit: () -> File,
private val prepareLevel: (Level) -> RepoState,
@@ -57,7 +68,7 @@ internal class GitEditorWorkflow(
currentRepo: RepoState,
invocation: GitEditorInvocation,
message: String,
): Pair<RepoState, List<String>> {
): GitEditorExecutionResult {
val nativeGit = requireNativeGit()
val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo)
return when (invocation.kind) {
@@ -83,7 +94,7 @@ internal class GitEditorWorkflow(
)
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,
invocation: GitEditorInvocation,
message: String,
): Pair<RepoState, List<String>> {
): GitEditorExecutionResult {
val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply {
parentFile?.mkdirs()
writeText(message)
}
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command)
val commandTokens = GitSandboxEngine.tokenizeCommand(invocation.command)
val arguments = commandTokens
.drop(1)
.toMutableList()
.apply { addAll(listOf("-F", messageFile.absolutePath)) }
val result = runGit(nativeGit, workingDir, arguments, emptyMap())
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(
@@ -119,7 +150,7 @@ internal class GitEditorWorkflow(
workingDir: File,
invocation: GitEditorInvocation,
todo: String,
): Pair<RepoState, List<String>> {
): GitEditorExecutionResult {
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
writeText(todo)
@@ -132,19 +163,106 @@ internal class GitEditorWorkflow(
|""".trimMargin(),
)
}
val capture = editorCaptureFiles(gitDir)
capture.content.delete()
capture.path.delete()
val messageEditorScript = createCaptureEditorScript(gitDir, capture)
val result = runGit(
nativeGit,
workingDir,
GitSandboxEngine.tokenizeCommand(invocation.command).drop(1),
mapOf(
"GIT_SEQUENCE_EDITOR" to editorCommand(editorScript),
"GIT_EDITOR" to "true",
"GIT_EDITOR" to editorCommand(messageEditorScript),
),
)
todoFile.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> {
@@ -166,6 +284,10 @@ internal class GitEditorWorkflow(
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 {
return "'" + replace("'", "'\"'\"'") + "'"
}

View File

@@ -513,14 +513,20 @@ fun GitHugApp() {
fun saveGitMessageEditor() {
val state = gitMessageEditorState ?: return
val (newRepo, lines) = runtime.executeGitEditorCommand(
val result = runtime.executeGitEditorCommandWithResult(
level = currentLevel,
currentRepo = repo,
invocation = state.invocation,
message = state.content,
)
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() {

View File

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

View File

@@ -265,6 +265,16 @@ class GitRepositoryRuntime private constructor(
invocation: GitEditorInvocation,
message: 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)
}

View File

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

View File

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

View File

@@ -18,5 +18,7 @@ internal fun addLevel(): Level = level(
testCases = listOf(
levelTestCase("add exact file", "git add README"),
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",
"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(
levelTestCase(

View File

@@ -28,5 +28,6 @@ internal fun branchAtLevel(): Level = level(
testCases = listOf(
levelTestCase("branch at previous commit", "git branch test_branch HEAD~1"),
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" },
testCases = listOf(
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" },
testCases = listOf(
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 },
testCases = listOf(
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(
levelTestCase("checkout tag", "git checkout 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(
levelTestCase("checkout tag namespace", "git checkout 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 } },
testCases = listOf(
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(
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(
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(
levelTestCase("commit with message", "git commit -m \"Initial commit\""),
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(
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("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(
levelTestCase("fetch origin", "git fetch origin"),
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" },
testCases = listOf(
levelTestCase("checkout old branch", "git checkout solve_world_hunger"),
levelTestCase("switch old branch", "git switch solve_world_hunger"),
),
setupChecks = listOf(
levelSetupCheck(

View File

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

View File

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

View File

@@ -37,5 +37,6 @@ internal fun mergeSquashLevel(): Level = level(
},
testCases = listOf(
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(
levelTestCase("pull explicit remote branch", "git pull origin master"),
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(
levelTestCase("push named branch", "git push origin 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(
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 upstream push", "git fetch", "git rebase origin/master", "git push"),
),
)

View File

@@ -35,5 +35,6 @@ internal fun pushTagsLevel(): Level = level(
testCases = listOf(
levelTestCase("push all tags", "git push --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(
levelTestCase("checkout feature then rebase", "git checkout feature", "git rebase master"),
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(
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 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" },
testCases = listOf(
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",
"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 } },
testCases = listOf(
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 } },
testCases = listOf(
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 } },
testCases = listOf(
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 } },
testCases = listOf(
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(
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 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") } },
testCases = listOf(
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 } },
testCases = listOf(
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(
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 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(
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 } },
testCases = listOf(
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 } },
testCases = listOf(
levelTestCase("stash changes", "git stash"),
levelTestCase("stash push", "git stash push"),
levelTestCase("stash with message", "git stash push -m \"save lyrics\""),
),
setupChecks = listOf(
levelSetupCheck(

View File

@@ -40,5 +40,11 @@ internal fun submoduleLevel(): Level = level(
"git config -f .gitmodules submodule.githug-include-me.url ../submodule-source",
"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 },
testCases = listOf(
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\""),
),
)