Require real merge commit and keep level tests embedded

This commit is contained in:
Joe Tretter
2026-05-19 16:18:58 -05:00
parent 5db02adbe9
commit ceb0d503f4
22 changed files with 201 additions and 93 deletions

View File

@@ -18,6 +18,7 @@ data class CommitNode(
val id: String,
val message: String,
val authorTimestampSeconds: Long? = null,
val parentCount: Int = 0,
)
data class InteractiveAddSession(
@@ -57,6 +58,8 @@ data class Level(
val setup: () -> RepoState,
val nativeSetup: (NativeLevelSetup.() -> Boolean)? = null,
val testCases: List<LevelTestCase> = emptyList(),
val negativeTestCases: List<LevelTestCase> = emptyList(),
val setupChecks: List<LevelSetupCheck> = emptyList(),
)
data class LevelTestCase(
@@ -64,4 +67,10 @@ data class LevelTestCase(
val commands: List<String>,
)
data class LevelSetupCheck(
val name: String,
val failureMessage: String,
val predicate: (RepoState) -> Boolean,
)
fun sampleLevels(): List<Level> = allGithugLevels()

View File

@@ -425,7 +425,7 @@ class GitRepositoryRuntime private constructor(
}
}
val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%at\t%s"))
val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%at\t%P\t%s"))
val branchResult = runGit(nativeGit, sandbox, listOf("branch", "--list"))
val remoteBranchResult = runGit(nativeGit, sandbox, listOf("branch", "-r", "--list"))
val tagResult = runGit(nativeGit, sandbox, listOf("tag", "--list"))
@@ -453,14 +453,16 @@ class GitRepositoryRuntime private constructor(
)
val commits = if (logResult.exitCode == 0) {
logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line ->
val parts = line.split('\t', limit = 3)
val parts = line.split('\t', limit = 4)
if (parts.isEmpty()) {
null
} else {
val parentHashes = parts.getOrNull(2).orEmpty().split(Regex("\\s+")).filter { it.isNotBlank() }
CommitNode(
id = parts[0],
authorTimestampSeconds = parts.getOrNull(1)?.toLongOrNull(),
message = parts.getOrElse(2) { "" },
parentCount = parentHashes.size,
message = parts.getOrElse(3) { "" },
)
}
}

View File

@@ -8,7 +8,14 @@ val RepoStateSaver = listSaver<RepoState, Any>(
state.initialized,
state.headBranch,
state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString(), it.deleted.toString()) },
state.commits.flatMap { listOf(it.id, it.message, it.authorTimestampSeconds?.toString().orEmpty()) },
state.commits.flatMap {
listOf(
it.id,
it.message,
it.authorTimestampSeconds?.toString().orEmpty(),
it.parentCount.toString(),
)
},
state.branches.flatMap { listOf(it.key, it.value.toString()) },
state.currentDir,
state.tags,
@@ -86,11 +93,21 @@ val RepoStateSaver = listSaver<RepoState, Any>(
)
private fun List<*>.restoreCommitNodes(): List<CommitNode> {
val hasTimestampColumn = size % 3 == 0 && chunked(3).all { chunk ->
val hasTimestampAndParentColumns = size % 4 == 0 && chunked(4).all { chunk ->
val timestamp = chunk.getOrNull(2) as? String
val parentCount = chunk.getOrNull(3) as? String
(timestamp.isNullOrEmpty() || timestamp.toLongOrNull()?.let { it >= 100_000_000L } == true) &&
parentCount?.toIntOrNull() != null
}
val hasTimestampColumn = !hasTimestampAndParentColumns && size % 3 == 0 && chunked(3).all { chunk ->
val timestamp = chunk.getOrNull(2) as? String
timestamp.isNullOrEmpty() || timestamp.toLongOrNull()?.let { it >= 100_000_000L } == true
}
val width = if (hasTimestampColumn) 3 else 2
val width = when {
hasTimestampAndParentColumns -> 4
hasTimestampColumn -> 3
else -> 2
}
return chunked(width).mapNotNull { chunk ->
val id = chunk.getOrNull(0) as? String ?: return@mapNotNull null
@@ -98,7 +115,12 @@ private fun List<*>.restoreCommitNodes(): List<CommitNode> {
CommitNode(
id = id,
message = message,
authorTimestampSeconds = if (hasTimestampColumn) (chunk.getOrNull(2) as? String)?.toLongOrNull() else null,
authorTimestampSeconds = if (hasTimestampAndParentColumns || hasTimestampColumn) {
(chunk.getOrNull(2) as? String)?.toLongOrNull()
} else {
null
},
parentCount = if (hasTimestampAndParentColumns) (chunk.getOrNull(3) as? String)?.toIntOrNull() ?: 0 else 0,
)
}
}

View File

@@ -92,4 +92,13 @@ internal fun bisectLevel(): Level = level(
"c8c7c00",
),
),
negativeTestCases = listOf(
levelTestCase(
"bisect run alone does not solve",
"git bisect start",
"git bisect bad HEAD",
"git bisect good known-good",
"git bisect run ./test-balance.sh",
),
),
)

View File

@@ -99,6 +99,14 @@ internal fun blameLevel(): Level = level(
testCases = listOf(
levelTestCase("answer author", "Spider Man"),
),
setupChecks = listOf(
levelSetupCheck(
"upstream password line is present",
"blame should start with upstream config.rb password line.",
) { repo ->
repo.files.firstOrNull { it.name == "config.rb" }?.content?.contains("@password = password || \"i<3evil\"") == true
},
),
)
private fun blameLevelFinalConfigRb(): String =

View File

@@ -61,6 +61,16 @@ internal fun cherryPickLevel(): Level = level(
testCases = listOf(
levelTestCase("cherry pick README commit", "git cherry-pick new-feature~2"),
),
setupChecks = listOf(
levelSetupCheck(
"upstream cherry-pick files and branch are present",
"cherry-pick should start with upstream README.md, hardcore-math.js, and new-feature branch.",
) { repo ->
repo.files.firstOrNull { it.name == "README.md" }?.content?.contains("I'll fill in the file some time later..") == true &&
repo.files.firstOrNull { it.name == "hardcore-math.js" }?.content?.contains("console.log(42 * i);") == true &&
repo.branches.containsKey("new-feature")
},
),
)
private fun cherryPickLevelHardcoreMathJs(): String =

View File

@@ -23,4 +23,7 @@ internal fun commitInFutureLevel(): Level = level(
testCases = listOf(
levelTestCase("commit with date option", "git commit --date 2037-01-01T00:00:00+0000 -m \"Future commit\""),
),
negativeTestCases = listOf(
levelTestCase("current date commit does not solve", "git commit -m \"Current date commit\""),
),
)

View File

@@ -41,7 +41,7 @@ internal fun conflictLevel(): Level = level(
validator = repoPredicate { repo ->
val poem = repo.files.find { it.name == "poem.txt" }?.content.orEmpty()
repo.headBranch == "master" &&
"merge" in repo.maintenanceActions &&
repo.commits.firstOrNull()?.parentCount == 2 &&
"Sat on a wall" in poem &&
poem.none { it in "<>=|" }
},
@@ -57,6 +57,27 @@ internal fun conflictLevel(): Level = level(
"git commit --no-edit",
),
),
negativeTestCases = listOf(
levelTestCase(
"editing conflict text and rerunning merge does not solve",
"git merge mybranch",
"echo \"Humpty dumpty\" > poem.txt",
"echo \"Sat on a wall\" >> poem.txt",
"echo \"Humpty dumpty\" >> poem.txt",
"echo \"Had a great fall\" >> poem.txt",
"git merge mybranch",
),
),
setupChecks = listOf(
levelSetupCheck(
"upstream poem starts on master",
"conflict should start on master with upstream poem text and mybranch present.",
) { repo ->
repo.headBranch == "master" &&
repo.fileContent("poem.txt").contains("Categorized shoes by color") &&
repo.branches.containsKey("mybranch")
},
),
)
private fun conflictLevelInitialPoem(): String =
@@ -88,3 +109,5 @@ private fun conflictLevelMasterPoem(): String =
Humpty dumpty
Had a great fall
""".trimIndent() + "\n"
private fun RepoState.fileContent(path: String): String = files.firstOrNull { it.name == path }?.content.orEmpty()

View File

@@ -33,4 +33,13 @@ internal fun deleteBranchLevel(): Level = level(
levelTestCase("delete branch", "git branch -d delete_me"),
levelTestCase("force delete branch", "git branch -D delete_me"),
),
setupChecks = listOf(
levelSetupCheck(
"upstream readme and delete branch are present",
"delete_branch should start with tracked readme and delete_me branch.",
) { repo ->
repo.files.any { it.name == "readme" && it.tracked } &&
repo.branches.containsKey("delete_me")
},
),
)

View File

@@ -32,6 +32,14 @@ internal fun diffLevel(): Level = level(
testCases = listOf(
levelTestCase("answer changed line", "26"),
),
setupChecks = listOf(
levelSetupCheck(
"upstream changed line is present",
"diff should start with app.rb modified to use server.json on the changed line.",
) { repo ->
repo.files.firstOrNull { it.name == "app.rb" }?.content?.contains("@message = get_response('server.json')") == true
},
),
)
internal fun diffLevelBaselineAppRb(): String =

View File

@@ -49,4 +49,7 @@ internal fun fetchLevel(): Level = level(
levelTestCase("fetch origin", "git fetch origin"),
levelTestCase("fetch default", "git fetch"),
),
negativeTestCases = listOf(
levelTestCase("pulling does not solve fetch", "git pull"),
),
)

View File

@@ -47,4 +47,13 @@ internal fun findOldBranchLevel(): Level = level(
testCases = listOf(
levelTestCase("checkout old branch", "git checkout solve_world_hunger"),
),
setupChecks = listOf(
levelSetupCheck(
"upstream branch puzzle shape is present",
"find_old_branch should start on master with upstream distractor branches and solve_world_hunger.",
) { repo ->
repo.headBranch == "master" &&
repo.branches.keys == setOf("blowup_sun_for_ransom", "cure_common_cold", "master", "solve_world_hunger")
},
),
)

View File

@@ -35,6 +35,15 @@ internal fun grepLevel(): Level = level(
testCases = listOf(
levelTestCase("answer todo count", "4"),
),
setupChecks = listOf(
levelSetupCheck(
"upstream TODO files are present",
"grep should start with upstream app.rb and config.rb TODO entries.",
) { repo ->
repo.files.firstOrNull { it.name == "app.rb" }?.content?.contains("# TODO Make site url variable.") == true &&
repo.files.firstOrNull { it.name == "config.rb" }?.content?.contains("# TODO Move password to a configuration file.") == true
},
),
)
private fun grepLevelAppRb(): String =

View File

@@ -68,6 +68,8 @@ internal fun level(
setup: () -> RepoState,
validator: (RepoState, String) -> Boolean,
testCases: List<LevelTestCase> = emptyList(),
negativeTestCases: List<LevelTestCase> = emptyList(),
setupChecks: List<LevelSetupCheck> = emptyList(),
nativeSetup: (NativeLevelSetup.() -> Boolean)? = null,
): Level = Level(
id = id,
@@ -79,6 +81,8 @@ internal fun level(
setup = setup,
nativeSetup = nativeSetup,
testCases = testCases,
negativeTestCases = negativeTestCases,
setupChecks = setupChecks,
)
internal fun levelTestCase(name: String, vararg commands: String): LevelTestCase = LevelTestCase(
@@ -86,6 +90,16 @@ internal fun levelTestCase(name: String, vararg commands: String): LevelTestCase
commands = commands.toList(),
)
internal fun levelSetupCheck(
name: String,
failureMessage: String,
predicate: (RepoState) -> Boolean,
): LevelSetupCheck = LevelSetupCheck(
name = name,
failureMessage = failureMessage,
predicate = predicate,
)
internal fun commandAnswer(vararg answers: String): (RepoState, String) -> Boolean = { _, command ->
val normalized = command.trim()
val matched = answers.firstOrNull { it.equals(normalized, ignoreCase = true) }

View File

@@ -32,4 +32,7 @@ internal fun mergeLevel(): Level = level(
testCases = listOf(
levelTestCase("merge feature", "git merge feature"),
),
negativeTestCases = listOf(
levelTestCase("switching to feature does not solve", "git switch feature"),
),
)

View File

@@ -33,4 +33,7 @@ internal fun reorderLevel(): Level = level(
"GIT_SEQUENCE_EDITOR=\"sed -i '2{h;d};3{G}'\" git rebase -i HEAD~3",
),
),
negativeTestCases = listOf(
levelTestCase("bare interactive rebase does not solve", "git rebase -i"),
),
)

View File

@@ -25,6 +25,14 @@ internal fun stashLevel(): Level = level(
testCases = listOf(
levelTestCase("stash changes", "git stash"),
),
setupChecks = listOf(
levelSetupCheck(
"upstream modified lyrics are present",
"stash should start with upstream lyrics plus the trailing Hey! modification.",
) { repo ->
repo.files.firstOrNull { it.name == "lyrics.txt" }?.content?.contains("Hear them loudly cry:\nHey!") == true
},
),
)
private fun stashLevelCommittedLyrics(): String =

View File

@@ -40,4 +40,12 @@ internal fun statusLevel(): Level = level(
testCases = listOf(
levelTestCase("answer untracked file", "database.yml"),
),
setupChecks = listOf(
levelSetupCheck(
"database.yml is the only untracked file",
"status should start with database.yml as the only untracked file.",
) { repo ->
repo.files.filter { !it.staged && !it.tracked && !it.deleted }.map { it.name } == listOf("database.yml")
},
),
)