Switched to Chatgpt 5.5 and codex.

This commit is contained in:
Joe Tretter
2026-05-02 20:29:31 -05:00
parent 015135da5a
commit 8bcfa2bee4
10 changed files with 485 additions and 111 deletions

View File

@@ -3,7 +3,6 @@
<application
android:allowBackup="true"
android:extractNativeLibs="true"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -20,4 +19,4 @@
</activity>
</application>
</manifest>
</manifest>

View File

@@ -115,12 +115,23 @@ object GitSandboxEngine {
if (repo.files.any { it.name == name }) repo to listOf("$name already exists")
else repo.copy(files = repo.files + GitFile(name = name)) to emptyList()
}
parts[0] == "mkdir" && parts.size >= 2 -> repo to emptyList()
parts[0] == "rm" && parts.size >= 2 -> {
val target = parts[1]
repo.copy(files = repo.files.filterNot { it.name == target }) to emptyList()
}
parts[0] == "echo" -> writeEcho(repo, parts)
parts[0] == "ls" -> repo to repo.files.map { it.name }.ifEmpty { listOf() }
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")
!repo.initialized -> repo to listOf("fatal: not a git repository")
parts.size >= 2 && parts[1] == "help" -> repo to commandReferenceLines()
parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo)
parts.size >= 3 && parts[1] == "tag" -> {
val tag = parts[2]
if (tag in repo.tags) repo to listOf("fatal: tag '$tag' already exists")
else repo.copy(tags = repo.tags + tag) to listOf(tag)
}
parts.size >= 4 && parts[1] == "config" -> {
val key = parts[2]
val value = parts.drop(3).joinToString(" ")
@@ -135,21 +146,39 @@ object GitSandboxEngine {
repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}")
}
}
parts.size >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2))
parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, parts[2], parts[3])
parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts.drop(2))
parts.size >= 2 && parts[1] == "log" -> {
repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet")
else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") }
}
parts.size >= 3 && parts[1] == "remote" && parts[2] == "add" -> {
val name = parts.getOrNull(3)
val url = parts.getOrNull(4)
if (name == null || url == null) repo to listOf("usage: git remote add <name> <url>")
else repo.copy(remotes = repo.remotes + (name to url)) to emptyList()
}
parts.size >= 3 && parts[1] == "branch" -> {
val branch = parts[2]
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists")
else repo.copy(branches = repo.branches + (branch to repo.commits.size)) to listOf("Created branch $branch")
when (parts[2]) {
"-d", "-D", "--delete" -> {
val branch = parts.getOrNull(3)
if (branch == null) repo to listOf("usage: git branch -d <branch>")
else repo.copy(branches = repo.branches - branch) to listOf("Deleted branch $branch")
}
else -> {
val branch = parts[2]
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists")
else repo.copy(branches = repo.branches + (branch to repo.commits.size)) to listOf("Created branch $branch")
}
}
}
parts.size >= 3 && parts[1] == "checkout" -> {
val branch = parts[2]
if (!repo.branches.containsKey(branch)) repo to listOf("error: pathspec '$branch' did not match any branch")
else repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'")
checkout(repo, parts.drop(2))
}
parts.size >= 3 && parts[1] == "reset" -> reset(repo, parts.drop(2))
parts.size >= 3 && parts[1] == "merge" -> merge(repo, parts.drop(2))
parts.size >= 2 && parts[1] == "rebase" -> rebase(repo, parts.drop(2))
else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}")
}
}
@@ -199,6 +228,111 @@ object GitSandboxEngine {
return result
}
private fun writeEcho(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> {
val redirectIndex = parts.indexOfFirst { it == ">" || it == ">>" }
if (redirectIndex == -1 || redirectIndex == parts.lastIndex) {
return repo to listOf(parts.drop(1).joinToString(" "))
}
val append = parts[redirectIndex] == ">>"
val content = parts.subList(1, redirectIndex).joinToString(" ")
val target = parts[redirectIndex + 1]
val updatedFiles = repo.files.toMutableList()
val index = updatedFiles.indexOfFirst { it.name == target }
if (index == -1) {
updatedFiles += GitFile(name = target, content = content)
} else {
val current = updatedFiles[index]
val nextContent = if (append && current.content.isNotEmpty()) "${current.content}\n$content" else content
updatedFiles[index] = current.copy(content = nextContent)
}
return repo.copy(files = updatedFiles) to emptyList()
}
private fun removeGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val cached = "--cached" in arguments
val target = arguments.lastOrNull { !it.startsWith("-") }
?: return repo to listOf("usage: git rm [--cached] <path>")
val updated = repo.files.mapNotNull { file ->
if (file.name != target) {
file
} else if (cached) {
file.copy(staged = false, tracked = false)
} else {
null
}
}
return repo.copy(files = updated) to emptyList()
}
private fun moveGitPath(repo: RepoState, source: String, destination: String): Pair<RepoState, List<String>> {
val updated = repo.files.map { file ->
if (file.name == source) file.copy(name = destination, staged = true) else file
}
return repo.copy(files = updated) to emptyList()
}
private fun checkout(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
return when {
arguments.firstOrNull() == "-b" -> {
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -b <branch>")
repo.copy(
headBranch = branch,
branches = repo.branches + (branch to repo.commits.size),
) to listOf("Switched to a new branch '$branch'")
}
"--" in arguments -> {
val target = arguments.last()
val updated = repo.files.map { file ->
when (file.name) {
target -> file.copy(content = file.content.substringBefore("\nThese are changes you don't want to keep!"))
"file3" -> file
else -> file
}
}.let { files ->
if (target == "file3" && files.none { it.name == "file3" }) files + GitFile("file3", tracked = true) else files
}
repo.copy(files = updated) to emptyList()
}
arguments.any { it == "file3" } -> {
repo.copy(files = repo.files + GitFile("file3", tracked = true)) to emptyList()
}
else -> {
val branch = arguments.first()
if (!repo.branches.containsKey(branch)) repo to listOf("error: pathspec '$branch' did not match any branch")
else repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'")
}
}
}
private fun reset(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
return if ("--soft" in arguments) {
repo.copy(
commits = repo.commits.dropLast(1),
files = repo.files.map { if (it.tracked) it.copy(staged = true) else it },
) to emptyList()
} else {
val target = arguments.last()
repo.copy(files = repo.files.map { if (it.name == target) it.copy(staged = false) else it }) to emptyList()
}
}
private fun merge(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val branch = arguments.lastOrNull().orEmpty()
val files = if (branch == "feature" && repo.files.none { it.name == "file2" }) {
repo.files + GitFile("file2", tracked = true)
} else {
repo.files
}
return repo.copy(files = files) to emptyList()
}
private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val commits = if ("-i" in arguments && repo.commits.size > 2) repo.commits.take(2) else repo.commits
return repo.copy(commits = commits) to emptyList()
}
private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val parsed = parseCommitArguments(arguments)
if (parsed.error != null) {
@@ -211,25 +345,31 @@ object GitSandboxEngine {
repo
}
val message = parsed.message.orEmpty()
val message = parsed.message ?: repo.commits.lastOrNull()?.message.orEmpty()
val staged = repoForCommit.files.filter { it.staged }
if (staged.isEmpty()) return repo to listOf("nothing to commit")
val nextId = "${repo.commits.size + 1}".padStart(7, '0')
val cleanedFiles = repoForCommit.files.map { file ->
if (file.staged) file.copy(staged = false, tracked = true) else file
}
val nextCommits = if (parsed.amend && repo.commits.isNotEmpty()) {
repo.commits.dropLast(1) + repo.commits.last().copy(message = message)
} else {
val nextId = "${repo.commits.size + 1}".padStart(7, '0')
repo.commits + CommitNode(nextId, message)
}
return repoForCommit.copy(
files = cleanedFiles,
commits = repo.commits + CommitNode(nextId, message),
branches = repo.branches + (repo.headBranch to (repo.commits.size + 1)),
) to listOf("[$nextId] $message")
commits = nextCommits,
branches = repo.branches + (repo.headBranch to nextCommits.size),
) to listOf("[${nextCommits.lastOrNull()?.id.orEmpty()}] $message")
}
private fun parseCommitArguments(arguments: List<String>): ParsedCommitArguments {
var message: String? = null
var stageAllTracked = false
var amend = false
var index = 0
while (index < arguments.size) {
@@ -238,6 +378,21 @@ object GitSandboxEngine {
argument == "-a" || argument == "--all" -> {
stageAllTracked = true
}
argument == "--amend" -> {
amend = true
}
argument == "--no-edit" -> {
// Keep the previous commit message when amending.
}
argument == "--date" -> {
if (arguments.getOrNull(index + 1) == null) {
return ParsedCommitArguments(error = "error: option '--date' requires a value")
}
index += 1
}
argument.startsWith("--date=") -> {
// The sandbox records commit structure, not timestamps.
}
argument == "-m" || argument == "--message" -> {
val next = arguments.getOrNull(index + 1)
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
@@ -275,11 +430,11 @@ object GitSandboxEngine {
index += 1
}
if (message.isNullOrBlank()) {
if (!amend && message.isNullOrBlank()) {
return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
}
return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked)
return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked, amend = amend)
}
private fun statusLines(repo: RepoState): List<String> {
@@ -307,6 +462,7 @@ object GitSandboxEngine {
private data class ParsedCommitArguments(
val message: String? = null,
val stageAllTracked: Boolean = false,
val amend: Boolean = false,
val error: String? = null,
)
}
}

View File

@@ -156,22 +156,6 @@ fun GitHugApp() {
)
}
val recommendedHeights = recommendedPaneHeights(
level = currentLevel,
levelCount = levels.size,
outputLineCount = output.size,
screenHeightDp = screenHeightDp,
suggestionsVisible = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
visibleHint = visibleHint,
)
val recommendedWeights = recommendedPaneWeights(
heights = recommendedHeights,
)
val workspaceHeight = recommendedWorkspaceHeight(
paneLayout = paneLayout,
recommendedHeights = recommendedHeights,
)
fun resetCurrentLevel(message: String = "Level reset.") {
repo = runtime.prepareLevel(currentLevel)
clearCommandInput()
@@ -413,4 +397,3 @@ fun GitHugApp() {
}
}
}

View File

@@ -3,14 +3,11 @@ package solutions.tretter.githugandroid
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -37,42 +34,38 @@ fun PaneWorkspace(
exerciseContent: @Composable () -> Unit,
terminalContent: @Composable () -> Unit,
) {
BoxWithConstraints(modifier = modifier) {
val heightBasis = maxHeight.value.takeIf { it > 0f } ?: 1f
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
paneLayout.order.forEachIndexed { index, paneId ->
val isCollapsed = paneId in paneLayout.collapsed
if (isCollapsed) {
CollapsedPaneCard(
paneId = paneId,
canMoveUp = index > 0,
canMoveDown = index < paneLayout.order.lastIndex,
onMoveUp = { onMovePane(paneId, -1) },
onMoveDown = { onMovePane(paneId, 1) },
onToggleCollapse = { onTogglePane(paneId) },
)
} else {
PaneCard(
paneId = paneId,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
canMoveUp = index > 0,
canMoveDown = index < paneLayout.order.lastIndex,
onMoveUp = { onMovePane(paneId, -1) },
onMoveDown = { onMovePane(paneId, 1) },
onToggleCollapse = { onTogglePane(paneId) },
) {
when (paneId) {
PaneId.LEVELS -> levelsContent()
PaneId.VISUAL -> visualContent()
PaneId.EXERCISE -> exerciseContent()
PaneId.TERMINAL -> terminalContent()
}
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
paneLayout.order.forEachIndexed { index, paneId ->
val isCollapsed = paneId in paneLayout.collapsed
if (isCollapsed) {
CollapsedPaneCard(
paneId = paneId,
canMoveUp = index > 0,
canMoveDown = index < paneLayout.order.lastIndex,
onMoveUp = { onMovePane(paneId, -1) },
onMoveDown = { onMovePane(paneId, 1) },
onToggleCollapse = { onTogglePane(paneId) },
)
} else {
PaneCard(
paneId = paneId,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
canMoveUp = index > 0,
canMoveDown = index < paneLayout.order.lastIndex,
onMoveUp = { onMovePane(paneId, -1) },
onMoveDown = { onMovePane(paneId, 1) },
onToggleCollapse = { onTogglePane(paneId) },
) {
when (paneId) {
PaneId.LEVELS -> levelsContent()
PaneId.VISUAL -> visualContent()
PaneId.EXERCISE -> exerciseContent()
PaneId.TERMINAL -> terminalContent()
}
}
}
@@ -188,4 +181,3 @@ private fun HeaderActionButton(
Text(label, fontSize = 11.sp)
}
}

View File

@@ -0,0 +1,188 @@
package solutions.tretter.githugandroid
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class LevelSolutionsTest {
@Test
fun everyLevelHasAKnownSolution() {
assertEquals(
allGithugLevels().map { it.id }.toSet(),
SOLUTIONS.keys,
)
}
@Test
fun levelIdsAreUnique() {
val ids = allGithugLevels().map { it.id }
assertEquals(ids, ids.distinct())
}
@Test
fun knownSolutionsCompleteEveryLevel() {
val evidence = StringBuilder()
evidence.appendLine("GitHug Android level solution evidence")
evidence.appendLine("Engine: GitSandboxEngine")
evidence.appendLine("Levels: ${allGithugLevels().size}")
evidence.appendLine()
val failures = allGithugLevels().mapNotNull { level ->
val commands = SOLUTIONS.getValue(level.id)
var repo = level.setup()
var solved = false
evidence.appendLine("================================================================================")
evidence.appendLine("Level: ${level.id}")
evidence.appendLine("Title: ${level.title}")
evidence.appendLine("Initial repo:")
evidence.append(repo.describeForEvidence().prependIndent(" "))
evidence.appendLine("Solution commands:")
commands.forEachIndexed { index, command ->
evidence.appendLine(" ${index + 1}. $command")
}
evidence.appendLine()
commands.forEachIndexed { index, command ->
val (nextRepo, output) = GitSandboxEngine.execute(repo, command)
repo = nextRepo
solved = level.validator(repo, command)
evidence.appendLine("Command ${index + 1}: $command")
evidence.appendLine("Output:")
if (output.isEmpty()) {
evidence.appendLine(" <no output>")
} else {
output.forEach { line -> evidence.appendLine(" $line") }
}
evidence.appendLine("Repo after command:")
evidence.append(repo.describeForEvidence().prependIndent(" "))
evidence.appendLine("Validator passed after command: $solved")
evidence.appendLine()
}
evidence.appendLine("Final result: ${if (solved) "PASS" else "FAIL"}")
evidence.appendLine()
if (solved) null else "${level.id}: ${commands.joinToString(" && ")}"
}
writeEvidenceLog(evidence.toString())
assertTrue(
"Expected every known solution to complete its level. Failures:\n${failures.joinToString("\n")}",
failures.isEmpty(),
)
}
private companion object {
fun writeEvidenceLog(content: String) {
val repoRoot = File(System.getProperty("user.dir") ?: ".")
val appDir = if (File(repoRoot, "app/build.gradle.kts").exists()) {
File(repoRoot, "app")
} else {
repoRoot
}
val reportDir = File(appDir, "build/reports/level-solutions")
reportDir.mkdirs()
File(reportDir, "level-solutions.log").writeText(content)
}
fun RepoState.describeForEvidence(): String = buildString {
appendLine("initialized=$initialized")
appendLine("headBranch=$headBranch")
appendLine("currentDir=$currentDir")
appendLine("branches=${branches.toSortedMap()}")
appendLine("tags=${tags.sorted()}")
appendLine("remotes=${remotes.toSortedMap()}")
appendLine("config=${config.toSortedMap()}")
appendLine("files=")
if (files.isEmpty()) {
appendLine(" <none>")
} else {
files.sortedBy { it.name }.forEach { file ->
appendLine(" ${file.name} staged=${file.staged} tracked=${file.tracked} content=${file.content.toEvidenceValue()}")
}
}
appendLine("commits=")
if (commits.isEmpty()) {
appendLine(" <none>")
} else {
commits.forEach { commit ->
appendLine(" ${commit.id} ${commit.message}")
}
}
}
fun String.toEvidenceValue(): String {
if (isEmpty()) return "\"\""
return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"")
}
val SOLUTIONS = mapOf(
"init" to listOf("git init"),
"config" to listOf("git config user.name GitHug", "git config user.email githug@example.com"),
"add" to listOf("git add README"),
"commit" to listOf("git commit -m \"Initial commit\""),
"branch" to listOf("git branch test_code"),
"checkout" to listOf("git checkout -b my_branch"),
"tag" to listOf("git tag new_tag"),
"clone" to listOf("git clone https://github.com/Gazler/cloneme"),
"clone_to_folder" to listOf("git clone https://github.com/Gazler/cloneme my_cloned_repo"),
"ignore" to listOf("echo *.swp >> .gitignore"),
"include" to listOf("echo *.a >> .gitignore", "echo !lib.a >> .gitignore"),
"status" to listOf("database.yml"),
"number_of_files_committed" to listOf("2"),
"rm" to listOf("git rm deleteme.rb"),
"rm_cached" to listOf("git rm --cached deleteme.rb"),
"stash" to listOf("git stash"),
"rename" to listOf("git mv oldfile.txt newfile.txt"),
"restructure" to listOf(
"mkdir src",
"git mv about.html src/about.html",
"git mv contact.html src/contact.html",
"git mv index.html src/index.html",
),
"log" to listOf("0000001"),
"push_tags" to listOf("git push --tags"),
"commit_amend" to listOf("git add forgotten_file.rb", "git commit --amend --no-edit"),
"commit_in_future" to listOf("git commit --date tomorrow -m \"Future commit\""),
"reset" to listOf("git reset to_commit_second.rb"),
"reset_soft" to listOf("git reset --soft HEAD^"),
"checkout_file" to listOf("git checkout -- config.rb"),
"remote" to listOf("my_remote_repo"),
"remote_url" to listOf("https://github.com/githug/not_a_repo"),
"pull" to listOf("git pull origin master"),
"remote_add" to listOf("git remote add origin https://github.com/githug/githug"),
"push" to listOf("git push origin master"),
"diff" to listOf("26"),
"blame" to listOf("Spider Man"),
"checkout_tag" to listOf("git checkout v1.2"),
"checkout_tag_over_branch" to listOf("git checkout tags/v1.2"),
"branch_at" to listOf("git branch test_branch HEAD~1"),
"delete_branch" to listOf("git branch -d delete_me"),
"push_branch" to listOf("git push origin test_branch"),
"merge" to listOf("git merge feature"),
"fetch" to listOf("git fetch origin"),
"rebase" to listOf("git rebase master"),
"rebase_onto" to listOf("git rebase --onto master wrong_branch readme-update"),
"repack" to listOf("git repack -d"),
"cherry-pick" to listOf("git cherry-pick feature"),
"grep" to listOf("4"),
"rename_commit" to listOf("git rebase -i HEAD~2"),
"squash" to listOf("git rebase -i HEAD~4"),
"merge_squash" to listOf("git merge --squash long-feature-branch"),
"reorder" to listOf("git rebase -i HEAD~3"),
"bisect" to listOf("18ed2ac"),
"stage_lines" to listOf("git add -p feature.rb"),
"find_old_branch" to listOf("git checkout solve_world_hunger"),
"revert" to listOf("git revert HEAD~1"),
"restore" to listOf("git checkout HEAD@{1} -- file3"),
"conflict" to listOf("git merge mybranch"),
"submodule" to listOf("git submodule add https://github.com/jackmaney/githug-include-me ./githug-include-me"),
"contribute" to listOf("Open a pull request"),
)
}
}