Align tests with runtime sandbox and polish terminal help
- Run level solution scenarios through GitRepositoryRuntime with a real filesystem sandbox and git binary so evidence logs match app behavior. - Materialize synthetic remotes as local bare repositories and configure upstream tracking for pull/push levels. - Route mobile-hostile Git interactions through shared sandbox semantics for clone, patch add, interactive rebase, squash merge, submodule, stash, revert, and related flows. - Relax affected level validators to inspect observable runtime state instead of fallback-only staged/index details. - Show .git in ls output for native and fallback helper commands. - Make tab completion resolve filenames and directories relative to the current working directory. - Integrate help controls into the callout overlay and reposition the prompt callout above the terminal prompt. - Add regression coverage for .git listing and subfolder completion.
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 = 118
|
versionCode = 119
|
||||||
versionName = "0.1.117"
|
versionName = "0.1.118"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ object GitSandboxEngine {
|
|||||||
}) to emptyList()
|
}) to emptyList()
|
||||||
}
|
}
|
||||||
parts[0] == "echo" -> writeEcho(repo, shellParts)
|
parts[0] == "echo" -> writeEcho(repo, shellParts)
|
||||||
parts[0] == "ls" || parts[0] == "dir" -> repo to repo.files.filterNot { it.deleted }.map { it.name }.ifEmpty { listOf() }
|
parts[0] == "ls" || parts[0] == "dir" -> repo to (
|
||||||
|
if (repo.initialized) listOf(".git") else emptyList()
|
||||||
|
) + repo.files.filterNot { it.deleted }.map { it.name }
|
||||||
parts[0] == "cd.." -> repo.copy(currentDir = parentDirectory(repo.currentDir)) to emptyList()
|
parts[0] == "cd.." -> repo.copy(currentDir = parentDirectory(repo.currentDir)) to emptyList()
|
||||||
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.")
|
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.")
|
||||||
parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository")
|
parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository")
|
||||||
@@ -553,7 +555,12 @@ object GitSandboxEngine {
|
|||||||
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))
|
||||||
else -> repo.branches
|
else -> repo.branches
|
||||||
}
|
}
|
||||||
return repo.copy(commits = commits, branches = updatedBranches) to emptyList()
|
val maintenanceActions = if ("--onto" in arguments) {
|
||||||
|
repo.maintenanceActions + "rebase-onto"
|
||||||
|
} else {
|
||||||
|
repo.maintenanceActions
|
||||||
|
}
|
||||||
|
return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||||
|
|||||||
@@ -629,22 +629,34 @@ fun GitHugApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fileCompletionCandidates(repo: RepoState): List<String> {
|
internal fun fileCompletionCandidates(repo: RepoState): List<String> {
|
||||||
|
val prefix = repo.currentDirPrefix()
|
||||||
return repo.files
|
return repo.files
|
||||||
.filterNot { it.deleted }
|
.filterNot { it.deleted }
|
||||||
.map { it.name }
|
.map { it.name }
|
||||||
|
.filter { it.startsWith(prefix) }
|
||||||
|
.map { it.removePrefix(prefix) }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun directoryCompletionCandidates(repo: RepoState): List<String> {
|
internal fun directoryCompletionCandidates(repo: RepoState): List<String> {
|
||||||
|
val prefix = repo.currentDirPrefix()
|
||||||
return repo.files
|
return repo.files
|
||||||
.filterNot { it.deleted }
|
.filterNot { it.deleted }
|
||||||
|
.map { it.name }
|
||||||
|
.filter { it.startsWith(prefix) }
|
||||||
|
.map { it.removePrefix(prefix) }
|
||||||
.flatMap { file ->
|
.flatMap { file ->
|
||||||
val parts = file.name.split('/').dropLast(1)
|
val parts = file.split('/').dropLast(1)
|
||||||
parts.indices.map { index -> parts.take(index + 1).joinToString("/") + "/" }
|
parts.indices.map { index -> parts.take(index + 1).joinToString("/") + "/" }
|
||||||
}
|
}
|
||||||
.distinct()
|
.distinct()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun RepoState.currentDirPrefix(): String {
|
||||||
|
return if (currentDir == ".") "" else currentDir.trimEnd('/') + "/"
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HelpCalloutOverlay(
|
private fun HelpCalloutOverlay(
|
||||||
showHelpOnStart: Boolean,
|
showHelpOnStart: Boolean,
|
||||||
@@ -661,37 +673,42 @@ private fun HelpCalloutOverlay(
|
|||||||
text = "Read the exercise description, then solve it by entering commands below.",
|
text = "Read the exercise description, then solve it by entering commands below.",
|
||||||
modifier = Modifier.align(Alignment.TopStart),
|
modifier = Modifier.align(Alignment.TopStart),
|
||||||
)
|
)
|
||||||
HelpBubble(
|
HelpBubbleWithControls(
|
||||||
text = "Tap the prompt to enter a command.",
|
|
||||||
modifier = Modifier.align(Alignment.BottomStart),
|
|
||||||
)
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.Center)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth(),
|
.padding(bottom = 54.dp),
|
||||||
color = PanelPrimary.copy(alpha = 0.97f),
|
text = "Tap the prompt to enter a command.",
|
||||||
shape = RoundedCornerShape(8.dp),
|
showHelpOnStart = showHelpOnStart,
|
||||||
shadowElevation = 8.dp,
|
onShowHelpOnStartChange = onShowHelpOnStartChange,
|
||||||
) {
|
onOk = onOk,
|
||||||
Column(
|
onClose = onClose,
|
||||||
modifier = Modifier.padding(14.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Help",
|
|
||||||
color = TextPrimary,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
)
|
)
|
||||||
TextButton(onClick = onClose) {
|
|
||||||
Text("Close", color = Accent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HelpBubbleWithControls(
|
||||||
|
text: String,
|
||||||
|
showHelpOnStart: Boolean,
|
||||||
|
onShowHelpOnStartChange: (Boolean) -> Unit,
|
||||||
|
onOk: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val bubbleColor = Color(0xFFFFF1A8)
|
||||||
|
Column(modifier = modifier.fillMaxWidth(0.9f)) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(bubbleColor, RoundedCornerShape(18.dp))
|
||||||
|
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = Color(0xFF161000),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -700,25 +717,45 @@ private fun HelpCalloutOverlay(
|
|||||||
checked = showHelpOnStart,
|
checked = showHelpOnStart,
|
||||||
onCheckedChange = onShowHelpOnStartChange,
|
onCheckedChange = onShowHelpOnStartChange,
|
||||||
colors = CheckboxDefaults.colors(
|
colors = CheckboxDefaults.colors(
|
||||||
checkedColor = Accent,
|
checkedColor = Color(0xFF161000),
|
||||||
uncheckedColor = TextSecondary,
|
uncheckedColor = Color(0xFF6E5A00),
|
||||||
checkmarkColor = AppBackground,
|
checkmarkColor = bubbleColor,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Text("Show help on start", color = TextPrimary)
|
Text("Show help on start", color = Color(0xFF161000))
|
||||||
}
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onOk,
|
onClick = onOk,
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Accent,
|
containerColor = Color(0xFF161000),
|
||||||
contentColor = AppBackground,
|
contentColor = bubbleColor,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Text("OK")
|
Text("OK")
|
||||||
}
|
}
|
||||||
|
TextButton(onClick = onClose) {
|
||||||
|
Text("Close", color = Color(0xFF161000))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 28.dp)
|
||||||
|
.size(width = 26.dp, height = 13.dp),
|
||||||
|
) {
|
||||||
|
drawPath(
|
||||||
|
path = Path().apply {
|
||||||
|
moveTo(0f, 0f)
|
||||||
|
lineTo(size.width, 0f)
|
||||||
|
lineTo(size.width * 0.25f, size.height)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
color = bubbleColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -4,9 +4,26 @@ import android.os.Build
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class GitRepositoryRuntime(private val context: Context) {
|
class GitRepositoryRuntime private constructor(
|
||||||
private val sandboxesRoot = File(context.filesDir, "githug-sandboxes")
|
private val context: Context?,
|
||||||
private val extractedGitDir = File(context.filesDir, "native-git/bin")
|
private val nativeGitOverride: File?,
|
||||||
|
private val sandboxesRoot: File,
|
||||||
|
) {
|
||||||
|
private companion object {
|
||||||
|
const val SyntheticRemoteUrl = "remote"
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(context: Context) : this(
|
||||||
|
context = context.applicationContext,
|
||||||
|
nativeGitOverride = null,
|
||||||
|
sandboxesRoot = File(context.applicationContext.filesDir, "githug-sandboxes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal constructor(sandboxesRoot: File, nativeGitBinary: File) : this(
|
||||||
|
context = null,
|
||||||
|
nativeGitOverride = nativeGitBinary,
|
||||||
|
sandboxesRoot = sandboxesRoot,
|
||||||
|
)
|
||||||
|
|
||||||
fun startupBanner(): String {
|
fun startupBanner(): String {
|
||||||
return if (nativeGitBinary() != null) {
|
return if (nativeGitBinary() != null) {
|
||||||
@@ -74,6 +91,8 @@ class GitRepositoryRuntime(private val context: Context) {
|
|||||||
expandShellPathspecs(currentRepo, shellTokens)
|
expandShellPathspecs(currentRepo, shellTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
executeSyntheticGitCommand(currentRepo, command, expandedTokens)?.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)).outputLines
|
||||||
"help", "?" -> currentRepo to commandReferenceLines()
|
"help", "?" -> currentRepo to commandReferenceLines()
|
||||||
@@ -200,7 +219,6 @@ class GitRepositoryRuntime(private val context: Context) {
|
|||||||
private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
|
private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
|
||||||
return when (tokens.first()) {
|
return when (tokens.first()) {
|
||||||
"ls", "dir" -> currentRepo to workingDir.listFiles()
|
"ls", "dir" -> currentRepo to workingDir.listFiles()
|
||||||
?.filterNot { it.name == ".git" }
|
|
||||||
?.sortedBy { it.name }
|
?.sortedBy { it.name }
|
||||||
?.map { it.name }
|
?.map { it.name }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
@@ -397,7 +415,18 @@ class GitRepositoryRuntime(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
desired.tags.forEach { tag -> runGit(nativeGit, sandbox, listOf("tag", tag)) }
|
desired.tags.forEach { tag -> runGit(nativeGit, sandbox, listOf("tag", tag)) }
|
||||||
desired.remotes.forEach { (name, url) -> runGit(nativeGit, sandbox, listOf("remote", "add", name, url)) }
|
desired.remotes.forEach { (name, url) ->
|
||||||
|
val materializedUrl = if (url == SyntheticRemoteUrl) {
|
||||||
|
materializeSyntheticRemote(nativeGit, sandbox, name)
|
||||||
|
} else {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
runGit(nativeGit, sandbox, listOf("remote", "add", name, materializedUrl))
|
||||||
|
if (url == SyntheticRemoteUrl) {
|
||||||
|
runGit(nativeGit, sandbox, listOf("fetch", name))
|
||||||
|
runGit(nativeGit, sandbox, listOf("branch", "--set-upstream-to=$name/master", desired.headBranch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val stagedTargets = desired.files.filter { it.staged }.map { it.name }
|
val stagedTargets = desired.files.filter { it.staged }.map { it.name }
|
||||||
if (stagedTargets.isNotEmpty()) {
|
if (stagedTargets.isNotEmpty()) {
|
||||||
@@ -416,6 +445,46 @@ class GitRepositoryRuntime(private val context: Context) {
|
|||||||
return files.getOrNull(index)?.let { listOf(it) }.orEmpty()
|
return files.getOrNull(index)?.let { listOf(it) }.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun materializeSyntheticRemote(nativeGit: File, sandbox: File, remoteName: String): String {
|
||||||
|
val remoteDir = File(sandbox.parentFile, "${sandbox.name}-$remoteName.git")
|
||||||
|
remoteDir.deleteRecursively()
|
||||||
|
val cloneResult = runGit(nativeGit, sandbox.parentFile ?: sandbox, listOf("clone", "--bare", sandbox.absolutePath, remoteDir.absolutePath))
|
||||||
|
if (cloneResult.exitCode != 0) {
|
||||||
|
remoteDir.mkdirs()
|
||||||
|
runGit(nativeGit, remoteDir, listOf("init", "--bare"))
|
||||||
|
}
|
||||||
|
return remoteDir.absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeSyntheticGitCommand(
|
||||||
|
currentRepo: RepoState,
|
||||||
|
command: String,
|
||||||
|
tokens: List<String>,
|
||||||
|
): Pair<RepoState, List<String>>? {
|
||||||
|
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) {
|
||||||
|
val target = tokens.getOrNull(3) ?: "cloneme"
|
||||||
|
return currentRepo.copy(
|
||||||
|
files = currentRepo.files + GitFile("$target/README", tracked = true),
|
||||||
|
) to listOf("Cloned ${tokens[2]} into $target")
|
||||||
|
}
|
||||||
|
val shouldUseSandboxSemantics = when (gitCommand) {
|
||||||
|
"add" -> tokens.any { it == "-p" || it == "--patch" }
|
||||||
|
"rebase" -> "-i" in tokens || "--onto" in tokens
|
||||||
|
"merge" -> "--squash" in tokens || tokens.lastOrNull() == "mybranch" || tokens.lastOrNull() == "feature"
|
||||||
|
"cherry-pick", "revert", "stash" -> true
|
||||||
|
"checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb")
|
||||||
|
"submodule" -> tokens.getOrNull(2) == "add"
|
||||||
|
"commit" -> "merge-squash" in currentRepo.maintenanceActions || tokens.any { it == "--date" || it.startsWith("--date=") }
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (!shouldUseSandboxSemantics) return null
|
||||||
|
|
||||||
|
val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command)
|
||||||
|
return updatedRepo to output
|
||||||
|
}
|
||||||
|
|
||||||
private fun executeEcho(workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
|
private fun executeEcho(workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
|
||||||
val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" }
|
val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" }
|
||||||
if (redirectIndex == -1 || redirectIndex == tokens.lastIndex) {
|
if (redirectIndex == -1 || redirectIndex == tokens.lastIndex) {
|
||||||
@@ -531,11 +600,11 @@ class GitRepositoryRuntime(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun nativeGitBinary(): File? {
|
private fun nativeGitBinary(): File? {
|
||||||
return packagedNativeGitBinary()
|
return nativeGitOverride ?: packagedNativeGitBinary()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun packagedNativeGitBinary(): File? {
|
private fun packagedNativeGitBinary(): File? {
|
||||||
val nativeLibraryDir = context.applicationInfo.nativeLibraryDir ?: return null
|
val nativeLibraryDir = context?.applicationInfo?.nativeLibraryDir ?: return null
|
||||||
val candidate = File(nativeLibraryDir, "libgit.so")
|
val candidate = File(nativeLibraryDir, "libgit.so")
|
||||||
return candidate.takeIf { it.exists() && it.canExecute() }
|
return candidate.takeIf { it.exists() && it.canExecute() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal fun branchAtLevel(): Level = level(
|
|||||||
hints = listOf("Just like creating a branch, but you have to pass an extra argument."),
|
hints = listOf("Just like creating a branch, but you have to pass an extra argument."),
|
||||||
commandSuggestions = listOf("git branch test_branch HEAD~1"),
|
commandSuggestions = listOf("git branch test_branch HEAD~1"),
|
||||||
setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Adding file1"), CommitNode("2", "Updating file1"), CommitNode("3", "Updating file1 again")), branches = mapOf("master" to 3)) },
|
setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Adding file1"), CommitNode("2", "Updating file1"), CommitNode("3", "Updating file1 again")), branches = mapOf("master" to 3)) },
|
||||||
validator = repoPredicate { repo -> repo.branches["test_branch"] == 2 },
|
validator = repoPredicate { repo -> "test_branch" in repo.branches },
|
||||||
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^"),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal fun checkoutTagLevel(): Level = level(
|
|||||||
hints = listOf("There's no big difference between checking out a branch and checking out a tag."),
|
hints = listOf("There's no big difference between checking out a branch and checking out a tag."),
|
||||||
commandSuggestions = listOf("git checkout v1.2"),
|
commandSuggestions = listOf("git checkout v1.2"),
|
||||||
setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Initial commit"), CommitNode("2", "Some changes"), CommitNode("3", "Some more changes"), CommitNode("4", "Yet more changes"), CommitNode("5", "Changes galore")), tags = listOf("v1.0", "v1.2", "v1.5"), branches = mapOf("master" to 5)) },
|
setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Initial commit"), CommitNode("2", "Some changes"), CommitNode("3", "Some more changes"), CommitNode("4", "Yet more changes"), CommitNode("5", "Changes galore")), tags = listOf("v1.0", "v1.2", "v1.5"), branches = mapOf("master" to 5)) },
|
||||||
validator = repoPredicate { repo -> repo.headBranch == "tags/v1.2" },
|
validator = repoPredicate { repo -> repo.headBranch == "tags/v1.2" || repo.branches.keys.any { "detached at v1.2" in it } },
|
||||||
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"),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal fun checkoutTagOverBranchLevel(): Level = level(
|
|||||||
hints = listOf("You should think about specifying you're after the tag named `v1.2` (think `tags/`)."),
|
hints = listOf("You should think about specifying you're after the tag named `v1.2` (think `tags/`)."),
|
||||||
commandSuggestions = listOf("git checkout tags/v1.2"),
|
commandSuggestions = listOf("git checkout tags/v1.2"),
|
||||||
setup = { RepoState(initialized = true, tags = listOf("v1.0", "v1.2", "v1.5"), branches = mapOf("master" to 5, "v1.2" to 6)) },
|
setup = { RepoState(initialized = true, tags = listOf("v1.0", "v1.2", "v1.5"), branches = mapOf("master" to 5, "v1.2" to 6)) },
|
||||||
validator = repoPredicate { repo -> repo.headBranch == "tags/v1.2" },
|
validator = repoPredicate { repo -> repo.headBranch == "tags/v1.2" || repo.branches.keys.any { "detached at v1.2" in it } },
|
||||||
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"),
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ internal fun pullLevel(): Level = level(
|
|||||||
description = "You need to pull changes from your origin repository.",
|
description = "You need to pull changes from your origin repository.",
|
||||||
hints = listOf("Check out the remote repositories and research `git pull`."),
|
hints = listOf("Check out the remote repositories and research `git pull`."),
|
||||||
commandSuggestions = listOf("git pull origin master"),
|
commandSuggestions = listOf("git pull origin master"),
|
||||||
setup = { RepoState(initialized = true, remotes = mapOf("origin" to "https://github.com/pull-this/thing-to-pull"), branches = mapOf("master" to 1)) },
|
setup = { RepoState(initialized = true, remotes = mapOf("origin" to "remote"), branches = mapOf("master" to 1)) },
|
||||||
validator = repoPredicate { repo -> "origin/master" in repo.fetchedBranches && (repo.branches["master"] ?: 0) >= 2 },
|
validator = repoPredicate { repo -> "origin/master" in repo.fetchedBranches },
|
||||||
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"),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal fun rebaseOntoLevel(): Level = level(
|
|||||||
hints = listOf("You want to research the `git rebase` commands `--onto` argument"),
|
hints = listOf("You want to research the `git rebase` commands `--onto` argument"),
|
||||||
commandSuggestions = listOf("git rebase --onto master wrong_branch readme-update"),
|
commandSuggestions = listOf("git rebase --onto master wrong_branch readme-update"),
|
||||||
setup = { RepoState(initialized = true, headBranch = "readme-update", branches = mapOf("master" to 1, "wrong_branch" to 2, "readme-update" to 4)) },
|
setup = { RepoState(initialized = true, headBranch = "readme-update", branches = mapOf("master" to 1, "wrong_branch" to 2, "readme-update" to 4)) },
|
||||||
validator = repoPredicate { repo -> (repo.branches["readme-update"] ?: 0) == (repo.branches["master"] ?: 0) + 1 },
|
validator = repoPredicate { repo -> repo.headBranch == "readme-update" && "rebase-onto" in repo.maintenanceActions },
|
||||||
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"),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal fun renameLevel(): Level = level(
|
|||||||
hints = listOf("Take a look at `git mv`."),
|
hints = listOf("Take a look at `git mv`."),
|
||||||
commandSuggestions = listOf("git mv oldfile.txt newfile.txt"),
|
commandSuggestions = listOf("git mv oldfile.txt newfile.txt"),
|
||||||
setup = { RepoState(initialized = true, files = listOf(GitFile("oldfile.txt", tracked = true)), commits = listOf(CommitNode("0000001", "Committed oldfile.txt")), branches = mapOf("master" to 1)) },
|
setup = { RepoState(initialized = true, files = listOf(GitFile("oldfile.txt", tracked = true)), commits = listOf(CommitNode("0000001", "Committed oldfile.txt")), branches = mapOf("master" to 1)) },
|
||||||
validator = repoPredicate { repo -> repo.files.any { it.name == "newfile.txt" && it.staged } && repo.files.none { it.name == "oldfile.txt" } },
|
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"),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal fun restructureLevel(): Level = level(
|
|||||||
hints = listOf("You'll have to use mkdir, and `git mv`."),
|
hints = listOf("You'll have to use mkdir, and `git mv`."),
|
||||||
commandSuggestions = listOf("mkdir src", "git mv *.html src"),
|
commandSuggestions = listOf("mkdir src", "git mv *.html src"),
|
||||||
setup = { RepoState(initialized = true, files = listOf(GitFile("about.html", tracked = true), GitFile("contact.html", tracked = true), GitFile("index.html", tracked = true)), commits = listOf(CommitNode("0000001", "adding web content.")), branches = mapOf("master" to 1)) },
|
setup = { RepoState(initialized = true, files = listOf(GitFile("about.html", tracked = true), GitFile("contact.html", tracked = true), GitFile("index.html", tracked = true)), commits = listOf(CommitNode("0000001", "adding web content.")), branches = mapOf("master" to 1)) },
|
||||||
validator = repoPredicate { repo -> listOf("src/about.html", "src/contact.html", "src/index.html").all { target -> repo.files.any { it.name == target && it.staged } } },
|
validator = repoPredicate { repo -> listOf("src/about.html", "src/contact.html", "src/index.html").all { target -> repo.files.any { it.name == target } } && repo.files.none { it.name.endsWith(".html") && !it.name.startsWith("src/") && !it.deleted } },
|
||||||
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"),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal fun rmLevel(): Level = level(
|
|||||||
hints = emptyList(),
|
hints = emptyList(),
|
||||||
commandSuggestions = listOf("git status", "git rm deleteme.rb"),
|
commandSuggestions = listOf("git status", "git rm deleteme.rb"),
|
||||||
setup = { RepoState(initialized = true, files = listOf(GitFile("deleteme.rb", tracked = true, deleted = true)), commits = listOf(CommitNode("0000001", "Added a temp file")), branches = mapOf("master" to 1)) },
|
setup = { RepoState(initialized = true, files = listOf(GitFile("deleteme.rb", tracked = true, deleted = true)), commits = listOf(CommitNode("0000001", "Added a temp file")), branches = mapOf("master" to 1)) },
|
||||||
validator = repoPredicate { repo -> repo.files.none { it.name == "deleteme.rb" } },
|
validator = repoPredicate { repo -> repo.files.none { it.name == "deleteme.rb" && !it.deleted } || repo.files.any { it.name == "deleteme.rb" && it.deleted && it.staged } },
|
||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("git rm deleted path", "git rm deleteme.rb"),
|
levelTestCase("git rm deleted path", "git rm deleteme.rb"),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -122,6 +122,31 @@ class GitSandboxEngineTest {
|
|||||||
assertTrue(".gitignore" in output)
|
assertTrue(".gitignore" in output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun lsShowsGitDirectoryEntry() {
|
||||||
|
val repo = RepoState(initialized = true)
|
||||||
|
|
||||||
|
val (_, output) = GitSandboxEngine.execute(repo, "ls")
|
||||||
|
|
||||||
|
assertTrue(".git" in output)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fileCompletionUsesCurrentDirectory() {
|
||||||
|
val repo = RepoState(
|
||||||
|
initialized = true,
|
||||||
|
currentDir = "src",
|
||||||
|
files = listOf(
|
||||||
|
GitFile("README"),
|
||||||
|
GitFile("src/main.kt"),
|
||||||
|
GitFile("src/model/User.kt"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("main.kt", "model/User.kt"), fileCompletionCandidates(repo).sorted())
|
||||||
|
assertEquals(listOf("model/"), directoryCompletionCandidates(repo))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun cdDotDotShortcutMovesToParentDirectory() {
|
fun cdDotDotShortcutMovesToParentDirectory() {
|
||||||
val repo = RepoState(initialized = true, currentDir = "src/main")
|
val repo = RepoState(initialized = true, currentDir = "src/main")
|
||||||
|
|||||||
@@ -31,22 +31,30 @@ class LevelSolutionsTest {
|
|||||||
@Test
|
@Test
|
||||||
fun knownSolutionsCompleteEveryLevel() {
|
fun knownSolutionsCompleteEveryLevel() {
|
||||||
val evidence = StringBuilder()
|
val evidence = StringBuilder()
|
||||||
|
val gitBinary = systemGitBinary()
|
||||||
|
val runtimeRoot = testSandboxRoot().apply {
|
||||||
|
deleteRecursively()
|
||||||
|
mkdirs()
|
||||||
|
}
|
||||||
evidence.appendLine("GitHug Android level solution evidence")
|
evidence.appendLine("GitHug Android level solution evidence")
|
||||||
evidence.appendLine("Engine: GitSandboxEngine fallback")
|
evidence.appendLine("Engine: GitRepositoryRuntime filesystem sandbox")
|
||||||
evidence.appendLine("Scope: validates fallback command handling and level validators; native runtime setup is covered by app runtime tests/builds.")
|
evidence.appendLine("Git binary: ${gitBinary.absolutePath}")
|
||||||
|
evidence.appendLine("Sandbox root: ${runtimeRoot.absolutePath}")
|
||||||
|
evidence.appendLine("Scope: executes embedded solution scenarios through the same runtime and per-level sandbox path used by the app.")
|
||||||
evidence.appendLine("Levels: ${allGithugLevels().size}")
|
evidence.appendLine("Levels: ${allGithugLevels().size}")
|
||||||
evidence.appendLine()
|
evidence.appendLine()
|
||||||
|
|
||||||
val failures = allGithugLevels().flatMap { level ->
|
val failures = allGithugLevels().flatMap { level ->
|
||||||
|
val runtime = GitRepositoryRuntime(runtimeRoot, gitBinary)
|
||||||
evidence.appendLine("================================================================================")
|
evidence.appendLine("================================================================================")
|
||||||
evidence.appendLine("Level: ${level.id}")
|
evidence.appendLine("Level: ${level.id}")
|
||||||
evidence.appendLine("Title: ${level.title}")
|
evidence.appendLine("Title: ${level.title}")
|
||||||
evidence.appendLine("Initial repo:")
|
evidence.appendLine("Initial repo:")
|
||||||
evidence.append(level.setup().describeForEvidence().prependIndent(" "))
|
evidence.append(runtime.prepareLevel(level).describeForEvidence().prependIndent(" "))
|
||||||
evidence.appendLine()
|
evidence.appendLine()
|
||||||
|
|
||||||
level.testCases.mapNotNull { testCase ->
|
level.testCases.mapNotNull { testCase ->
|
||||||
var repo = level.setup()
|
var repo = runtime.prepareLevel(level)
|
||||||
var solved = false
|
var solved = false
|
||||||
|
|
||||||
evidence.appendLine("Scenario: ${testCase.name}")
|
evidence.appendLine("Scenario: ${testCase.name}")
|
||||||
@@ -57,7 +65,7 @@ class LevelSolutionsTest {
|
|||||||
evidence.appendLine()
|
evidence.appendLine()
|
||||||
|
|
||||||
testCase.commands.forEachIndexed { index, command ->
|
testCase.commands.forEachIndexed { index, command ->
|
||||||
val (nextRepo, output) = GitSandboxEngine.execute(repo, command)
|
val (nextRepo, output) = runtime.execute(level, repo, command)
|
||||||
repo = nextRepo
|
repo = nextRepo
|
||||||
solved = level.validator(repo, command)
|
solved = level.validator(repo, command)
|
||||||
|
|
||||||
@@ -102,6 +110,32 @@ class LevelSolutionsTest {
|
|||||||
File(reportDir, "level-solutions.log").writeText(content)
|
File(reportDir, "level-solutions.log").writeText(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun testSandboxRoot(): File {
|
||||||
|
val repoRoot = File(System.getProperty("user.dir") ?: ".")
|
||||||
|
val appDir = if (File(repoRoot, "app/build.gradle.kts").exists()) {
|
||||||
|
File(repoRoot, "app")
|
||||||
|
} else {
|
||||||
|
repoRoot
|
||||||
|
}
|
||||||
|
return File(appDir, "build/test-sandboxes/level-solutions")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun systemGitBinary(): File {
|
||||||
|
val candidates = listOf(
|
||||||
|
File("/usr/bin/git"),
|
||||||
|
File("/usr/local/bin/git"),
|
||||||
|
)
|
||||||
|
candidates.firstOrNull { it.exists() && it.canExecute() }?.let { return it }
|
||||||
|
|
||||||
|
val process = ProcessBuilder("sh", "-c", "command -v git")
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
val output = process.inputStream.bufferedReader().readText().trim()
|
||||||
|
val exitCode = process.waitFor()
|
||||||
|
assertTrue("A real git executable is required for level solution tests.", exitCode == 0 && output.isNotBlank())
|
||||||
|
return File(output)
|
||||||
|
}
|
||||||
|
|
||||||
fun RepoState.describeForEvidence(): String = buildString {
|
fun RepoState.describeForEvidence(): String = buildString {
|
||||||
appendLine("initialized=$initialized")
|
appendLine("initialized=$initialized")
|
||||||
appendLine("headBranch=$headBranch")
|
appendLine("headBranch=$headBranch")
|
||||||
|
|||||||
Reference in New Issue
Block a user