Remove brittle interactive rebase shortcuts
This commit is contained in:
@@ -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
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
|
.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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 originalCommits = repo.commits
|
val target = arguments.lastOrNull { it != "-i" && it != "--interactive" && !it.startsWith("-") }
|
||||||
val commits = if (interactive && repo.commits.size > 2) {
|
if (interactive && target == null) {
|
||||||
repo.commits
|
return repo to listOf("fatal: No rebase upstream specified")
|
||||||
.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 originalCommits = repo.commits
|
||||||
|
val commits = 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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user