Remove brittle interactive rebase shortcuts

This commit is contained in:
Joe Tretter
2026-05-18 18:37:10 -05:00
parent 06b56010d0
commit d4e2c8714c
8 changed files with 1172 additions and 45 deletions

View File

@@ -19,8 +19,8 @@ android {
applicationId = "solutions.tretter.githugandroid" applicationId = "solutions.tretter.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 146 versionCode = 147
versionName = "0.1.145" versionName = "0.1.146"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

1088
app/src/main/GitTesting.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -127,7 +127,8 @@ class GitRepositoryRuntime private constructor(
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot .takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
val shellTokens = GitSandboxEngine.tokenizeShellCommand(command) 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 (tokens.isEmpty()) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to emptyList()
if (currentRepo.interactiveAddSession != null) { if (currentRepo.interactiveAddSession != null) {
val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command) val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command)
@@ -146,13 +147,13 @@ class GitRepositoryRuntime private constructor(
val expandedTokens = if (tokens.firstOrNull() == "echo") { val expandedTokens = if (tokens.firstOrNull() == "echo") {
tokens tokens
} else { } else {
expandShellPathspecs(currentRepo, shellTokens) expandShellPathspecs(currentRepo, invocation.command)
}.normalizeGitStageAlias() }.normalizeGitStageAlias()
executeSyntheticGitCommand(currentRepo, command, expandedTokens)?.let { return it } executeSyntheticGitCommand(currentRepo, command, expandedTokens, invocation.environment)?.let { return it }
val result = when (expandedTokens.first()) { 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() "help", "?" -> currentRepo to commandReferenceLines()
else -> executeHelperCommand(sandboxRoot, workingDir, currentRepo, expandedTokens) else -> executeHelperCommand(sandboxRoot, workingDir, currentRepo, expandedTokens)
} }
@@ -551,7 +552,9 @@ class GitRepositoryRuntime private constructor(
currentRepo: RepoState, currentRepo: RepoState,
command: String, command: String,
tokens: List<String>, tokens: List<String>,
environment: Map<String, String> = emptyMap(),
): Pair<RepoState, List<String>>? { ): Pair<RepoState, List<String>>? {
if (environment.isNotEmpty()) return null
if (tokens.firstOrNull() != "git") return null if (tokens.firstOrNull() != "git") return null
val gitCommand = tokens.getOrNull(1) ?: return null val gitCommand = tokens.getOrNull(1) ?: return null
if (gitCommand == "clone" && tokens.getOrNull(2)?.startsWith("https://github.com/Gazler/cloneme") == true) { 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) { val shouldUseSandboxSemantics = when (gitCommand) {
"add" -> tokens.any { it == "-p" || it == "--patch" || it == "-i" || it == "--interactive" } "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" "merge" -> "--squash" in tokens || tokens.lastOrNull() == "mybranch" || tokens.lastOrNull() == "feature"
"revert", "stash" -> true "revert", "stash" -> true
"checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb") "checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb")
@@ -829,11 +832,47 @@ class GitRepositoryRuntime private constructor(
return (first + second).distinct() return (first + second).distinct()
} }
private fun runGit(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult { private data class EnvironmentPrefixedCommand(
return runProcess(binary, workingDir, arguments) 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 { return try {
val gitExecPath = gitExecDirectory(binary) val gitExecPath = gitExecDirectory(binary)
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments) val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
@@ -842,13 +881,17 @@ class GitRepositoryRuntime private constructor(
.apply { .apply {
environment()["HOME"] = workingDir.absolutePath environment()["HOME"] = workingDir.absolutePath
environment()["GIT_EXEC_PATH"] = gitExecPath.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_CONFIG_NOSYSTEM"] = "1"
environment()["GIT_AUTHOR_NAME"] = "GitHug" environment()["GIT_AUTHOR_NAME"] = "GitHug"
environment()["GIT_AUTHOR_EMAIL"] = "githug@example.com" environment()["GIT_AUTHOR_EMAIL"] = "githug@example.com"
environment()["GIT_COMMITTER_NAME"] = "GitHug" environment()["GIT_COMMITTER_NAME"] = "GitHug"
environment()["GIT_COMMITTER_EMAIL"] = "githug@example.com" environment()["GIT_COMMITTER_EMAIL"] = "githug@example.com"
environment()["LC_ALL"] = "C" environment()["LC_ALL"] = "C"
environment().putAll(extraEnvironment)
} }
.start() .start()

View File

@@ -515,28 +515,12 @@ object GitSandboxEngine {
private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> { private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val interactive = "-i" in arguments || "--interactive" in arguments val interactive = "-i" in arguments || "--interactive" in arguments
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 originalCommits = repo.commits
val commits = if (interactive && repo.commits.size > 2) { val commits = repo.commits
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 updatedBranches = when { val updatedBranches = when {
"--onto" in arguments -> repo.branches + (repo.headBranch to (repo.branches["master"] ?: 0) + 1) "--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)) 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 { } else {
repo.maintenanceActions 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 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 target = arguments.lastOrNull { it != "-i" && it != "--interactive" }.orEmpty()
val rebasedIds = rebasedCommits.map { it.id }.toSet()
return buildList { return buildList {
originalCommits.forEach { commit -> originalCommits.forEach { commit ->
val action = when { add("pick ${commit.id} ${commit.message}")
commit.id !in rebasedIds -> "squash"
commit.message == "First coommit" -> "reword"
else -> "pick"
}
add("$action ${commit.id} ${commit.message}")
} }
add("") add("")
add("# Rebase ${target.ifBlank { "HEAD" }} in progress; onto HEAD") add("# Rebase ${target.ifBlank { "HEAD" }} in progress; onto HEAD")

View File

@@ -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") } }, validator = repoPredicate { repo -> repo.commits.any { it.message == "First commit" } && repo.commits.none { it.message.contains("coommit") } },
testCases = listOf( 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",
),
), ),
) )

View File

@@ -26,8 +26,11 @@ internal fun reorderLevel(): Level = level(
addCommit("Second commit", "file2") addCommit("Second commit", "file2")
true 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( 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",
),
), ),
) )

View File

@@ -30,6 +30,9 @@ internal fun squashLevel(): Level = level(
}, },
validator = repoPredicate { repo -> repo.commits.size <= 2 && repo.commits.any { it.message == "Adding README" } }, validator = repoPredicate { repo -> repo.commits.size <= 2 && repo.commits.any { it.message == "Adding README" } },
testCases = listOf( 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",
),
), ),
) )

View File

@@ -121,6 +121,15 @@ class LevelSolutionsTest {
val fetchRepo = fetchRuntime.prepareLevel(fetchExercise) val fetchRepo = fetchRuntime.prepareLevel(fetchExercise)
val (pulledRepo, _) = fetchRuntime.execute(fetchExercise, fetchRepo, "git pull") val (pulledRepo, _) = fetchRuntime.execute(fetchExercise, fetchRepo, "git pull")
assertFalse("Pulling must not solve the fetch level.", fetchExercise.validator(pulledRepo, "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 { private companion object {