Remove brittle interactive rebase shortcuts
This commit is contained in:
1088
app/src/main/GitTesting.md
Normal file
1088
app/src/main/GitTesting.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -127,7 +127,8 @@ class GitRepositoryRuntime private constructor(
|
||||
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
|
||||
|
||||
val shellTokens = GitSandboxEngine.tokenizeShellCommand(command)
|
||||
val tokens = shellTokens.map { it.value }
|
||||
val invocation = parseEnvironmentPrefixedCommand(shellTokens)
|
||||
val tokens = invocation.command.map { it.value }
|
||||
if (tokens.isEmpty()) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to emptyList()
|
||||
if (currentRepo.interactiveAddSession != null) {
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command)
|
||||
@@ -146,13 +147,13 @@ class GitRepositoryRuntime private constructor(
|
||||
val expandedTokens = if (tokens.firstOrNull() == "echo") {
|
||||
tokens
|
||||
} else {
|
||||
expandShellPathspecs(currentRepo, shellTokens)
|
||||
expandShellPathspecs(currentRepo, invocation.command)
|
||||
}.normalizeGitStageAlias()
|
||||
|
||||
executeSyntheticGitCommand(currentRepo, command, expandedTokens)?.let { return it }
|
||||
executeSyntheticGitCommand(currentRepo, command, expandedTokens, invocation.environment)?.let { return it }
|
||||
|
||||
val result = when (expandedTokens.first()) {
|
||||
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1)).outputLines
|
||||
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1), invocation.environment).outputLines
|
||||
"help", "?" -> currentRepo to commandReferenceLines()
|
||||
else -> executeHelperCommand(sandboxRoot, workingDir, currentRepo, expandedTokens)
|
||||
}
|
||||
@@ -551,7 +552,9 @@ class GitRepositoryRuntime private constructor(
|
||||
currentRepo: RepoState,
|
||||
command: String,
|
||||
tokens: List<String>,
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): Pair<RepoState, List<String>>? {
|
||||
if (environment.isNotEmpty()) return null
|
||||
if (tokens.firstOrNull() != "git") return null
|
||||
val gitCommand = tokens.getOrNull(1) ?: return null
|
||||
if (gitCommand == "clone" && tokens.getOrNull(2)?.startsWith("https://github.com/Gazler/cloneme") == true) {
|
||||
@@ -562,7 +565,7 @@ class GitRepositoryRuntime private constructor(
|
||||
}
|
||||
val shouldUseSandboxSemantics = when (gitCommand) {
|
||||
"add" -> tokens.any { it == "-p" || it == "--patch" || it == "-i" || it == "--interactive" }
|
||||
"rebase" -> "-i" in tokens || "--interactive" in tokens || "--onto" in tokens
|
||||
"rebase" -> "--onto" in tokens
|
||||
"merge" -> "--squash" in tokens || tokens.lastOrNull() == "mybranch" || tokens.lastOrNull() == "feature"
|
||||
"revert", "stash" -> true
|
||||
"checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb")
|
||||
@@ -829,11 +832,47 @@ class GitRepositoryRuntime private constructor(
|
||||
return (first + second).distinct()
|
||||
}
|
||||
|
||||
private fun runGit(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
|
||||
return runProcess(binary, workingDir, arguments)
|
||||
private data class EnvironmentPrefixedCommand(
|
||||
val environment: Map<String, String>,
|
||||
val command: List<GitSandboxEngine.ShellToken>,
|
||||
)
|
||||
|
||||
private fun parseEnvironmentPrefixedCommand(tokens: List<GitSandboxEngine.ShellToken>): EnvironmentPrefixedCommand {
|
||||
val environment = linkedMapOf<String, String>()
|
||||
var commandStart = 0
|
||||
while (commandStart < tokens.size) {
|
||||
val value = tokens[commandStart].value
|
||||
val assignmentIndex = value.indexOf('=')
|
||||
if (assignmentIndex <= 0) break
|
||||
val name = value.take(assignmentIndex)
|
||||
if (!name.isShellEnvironmentName()) break
|
||||
environment[name] = value.substring(assignmentIndex + 1)
|
||||
commandStart += 1
|
||||
}
|
||||
return EnvironmentPrefixedCommand(environment, tokens.drop(commandStart))
|
||||
}
|
||||
|
||||
private fun runProcess(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
|
||||
private fun String.isShellEnvironmentName(): Boolean {
|
||||
if (isEmpty()) return false
|
||||
if (first() != '_' && !first().isLetter()) return false
|
||||
return all { it == '_' || it.isLetterOrDigit() }
|
||||
}
|
||||
|
||||
private fun runGit(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult {
|
||||
return runProcess(binary, workingDir, arguments, environment)
|
||||
}
|
||||
|
||||
private fun runProcess(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
extraEnvironment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult {
|
||||
return try {
|
||||
val gitExecPath = gitExecDirectory(binary)
|
||||
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
|
||||
@@ -842,13 +881,17 @@ class GitRepositoryRuntime private constructor(
|
||||
.apply {
|
||||
environment()["HOME"] = workingDir.absolutePath
|
||||
environment()["GIT_EXEC_PATH"] = gitExecPath.absolutePath
|
||||
environment()["PATH"] = gitExecPath.absolutePath
|
||||
environment()["PATH"] = listOfNotNull(
|
||||
gitExecPath.absolutePath,
|
||||
System.getenv("PATH")?.takeIf { it.isNotBlank() },
|
||||
).joinToString(File.pathSeparator)
|
||||
environment()["GIT_CONFIG_NOSYSTEM"] = "1"
|
||||
environment()["GIT_AUTHOR_NAME"] = "GitHug"
|
||||
environment()["GIT_AUTHOR_EMAIL"] = "githug@example.com"
|
||||
environment()["GIT_COMMITTER_NAME"] = "GitHug"
|
||||
environment()["GIT_COMMITTER_EMAIL"] = "githug@example.com"
|
||||
environment()["LC_ALL"] = "C"
|
||||
environment().putAll(extraEnvironment)
|
||||
}
|
||||
.start()
|
||||
|
||||
|
||||
@@ -515,28 +515,12 @@ object GitSandboxEngine {
|
||||
|
||||
private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val interactive = "-i" in arguments || "--interactive" in arguments
|
||||
val originalCommits = repo.commits
|
||||
val commits = if (interactive && repo.commits.size > 2) {
|
||||
repo.commits
|
||||
.filterNot { it.message.contains("squash this commit", ignoreCase = true) }
|
||||
.map { if (it.message == "First coommit") it.copy(message = "First commit") else it }
|
||||
.let { ordered ->
|
||||
if (ordered.map { it.message }.containsAll(listOf("First commit", "Second commit", "Third commit"))) {
|
||||
ordered.sortedBy { commit ->
|
||||
when (commit.message) {
|
||||
"First commit" -> 1
|
||||
"Second commit" -> 2
|
||||
"Third commit" -> 3
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ordered
|
||||
}
|
||||
}
|
||||
} else {
|
||||
repo.commits
|
||||
val target = arguments.lastOrNull { it != "-i" && it != "--interactive" && !it.startsWith("-") }
|
||||
if (interactive && target == null) {
|
||||
return repo to listOf("fatal: No rebase upstream specified")
|
||||
}
|
||||
val originalCommits = repo.commits
|
||||
val commits = repo.commits
|
||||
val updatedBranches = when {
|
||||
"--onto" in arguments -> repo.branches + (repo.headBranch to (repo.branches["master"] ?: 0) + 1)
|
||||
arguments.isNotEmpty() -> repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, repo.branches[arguments.last()] ?: 0))
|
||||
@@ -547,21 +531,15 @@ object GitSandboxEngine {
|
||||
} else {
|
||||
repo.maintenanceActions
|
||||
}
|
||||
val output = if (interactive) interactiveRebaseConsoleLines(originalCommits, commits, arguments, repo.headBranch) else emptyList()
|
||||
val output = if (interactive) interactiveRebaseConsoleLines(originalCommits, arguments, repo.headBranch) else emptyList()
|
||||
return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to output
|
||||
}
|
||||
|
||||
private fun interactiveRebaseConsoleLines(originalCommits: List<CommitNode>, rebasedCommits: List<CommitNode>, arguments: List<String>, branch: String): List<String> {
|
||||
private fun interactiveRebaseConsoleLines(originalCommits: List<CommitNode>, arguments: List<String>, branch: String): List<String> {
|
||||
val target = arguments.lastOrNull { it != "-i" && it != "--interactive" }.orEmpty()
|
||||
val rebasedIds = rebasedCommits.map { it.id }.toSet()
|
||||
return buildList {
|
||||
originalCommits.forEach { commit ->
|
||||
val action = when {
|
||||
commit.id !in rebasedIds -> "squash"
|
||||
commit.message == "First coommit" -> "reword"
|
||||
else -> "pick"
|
||||
}
|
||||
add("$action ${commit.id} ${commit.message}")
|
||||
add("pick ${commit.id} ${commit.message}")
|
||||
}
|
||||
add("")
|
||||
add("# Rebase ${target.ifBlank { "HEAD" }} in progress; onto HEAD")
|
||||
|
||||
@@ -26,6 +26,9 @@ internal fun renameCommitLevel(): Level = level(
|
||||
},
|
||||
validator = repoPredicate { repo -> repo.commits.any { it.message == "First commit" } && repo.commits.none { it.message.contains("coommit") } },
|
||||
testCases = listOf(
|
||||
levelTestCase("interactive rebase rename", "git rebase -i HEAD~2"),
|
||||
levelTestCase(
|
||||
"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",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -26,8 +26,11 @@ internal fun reorderLevel(): Level = level(
|
||||
addCommit("Second commit", "file2")
|
||||
true
|
||||
},
|
||||
validator = repoPredicate { repo -> repo.commits.map { it.message }.filter { it.endsWith("commit") } == listOf("First commit", "Second commit", "Third commit") },
|
||||
validator = repoPredicate { repo -> repo.commits.asReversed().map { it.message }.filter { it.endsWith("commit") } == listOf("First commit", "Second commit", "Third commit") },
|
||||
testCases = listOf(
|
||||
levelTestCase("interactive reorder", "git rebase -i HEAD~3"),
|
||||
levelTestCase(
|
||||
"interactive reorder",
|
||||
"GIT_SEQUENCE_EDITOR=\"sed -i '2{h;d};3{G}'\" git rebase -i HEAD~3",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -30,6 +30,9 @@ internal fun squashLevel(): Level = level(
|
||||
},
|
||||
validator = repoPredicate { repo -> repo.commits.size <= 2 && repo.commits.any { it.message == "Adding README" } },
|
||||
testCases = listOf(
|
||||
levelTestCase("interactive squash", "git rebase -i HEAD~4"),
|
||||
levelTestCase(
|
||||
"interactive squash",
|
||||
"GIT_SEQUENCE_EDITOR=\"sed -i '2,\$s/^pick /squash /'\" GIT_EDITOR=true git rebase -i HEAD~4",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -121,6 +121,15 @@ class LevelSolutionsTest {
|
||||
val fetchRepo = fetchRuntime.prepareLevel(fetchExercise)
|
||||
val (pulledRepo, _) = fetchRuntime.execute(fetchExercise, fetchRepo, "git pull")
|
||||
assertFalse("Pulling must not solve the fetch level.", fetchExercise.validator(pulledRepo, "git pull"))
|
||||
|
||||
val reorderExercise = reorderLevel()
|
||||
val reorderRuntime = GitRepositoryRuntime(runtimeRoot, gitBinary)
|
||||
val reorderRepo = reorderRuntime.prepareLevel(reorderExercise)
|
||||
val (bareInteractiveRebaseRepo, _) = reorderRuntime.execute(reorderExercise, reorderRepo, "git rebase -i")
|
||||
assertFalse(
|
||||
"Starting an interactive rebase without an upstream/range must not solve the reorder level.",
|
||||
reorderExercise.validator(bareInteractiveRebaseRepo, "git rebase -i"),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
Reference in New Issue
Block a user