Files
llm-testbench/internal/tests/delivery_git.go
T
lukaszraczylo 499956f8cc fix(tests): disambiguate two prompts, exempt --force-if-includes
git-detached-head-recovery: all three benchmarked models answered
["branch"], which the prompt's literal ask (save the commits) makes
correct while the evaluator requires ending attached. The prompt now
demands ending checked out on the new branch and names the bare-branch
gap explicitly.

scen-crashloop-order: the instruction excluded only acting steps, so
including the read-only journalctl step E followed the letter of the
prompt. The instruction now asks for the smallest sufficient set and
excludes redundant diagnostics.

git-force-with-lease-vs-force: exempt --force-if-includes, the
stricter safe variant, from the bare --force pattern.
2026-08-30 14:19:08 +01:00

461 lines
20 KiB
Go

package tests
import (
"context"
"fmt"
"regexp"
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
)
func registerDeliveryGitTests(r *testkit.Registry) {
r.Register(delGitBisectStepsTest())
r.Register(delGitRebaseVsMergeTest())
r.Register(delGitConventionalCommitClassifyTest())
r.Register(delGitWorktreeUseCaseTest())
r.Register(delGitForceWithLeaseVsForceTest())
r.Register(delGitHookChoiceTest())
r.Register(delGitDetachedHeadRecoveryTest())
r.Register(delGitCherryPickVsRevertTest())
r.Register(delGitGitignoreNegationTraceTest())
r.Register(delGitStashPopConflictTest())
}
// --- negation-aware helpers, shared across delivery_git.go and
// delivery_release.go (same package), delegating to eval.NoUnnegatedMention
// (D5) - the primitive unifying this file's, kubernetes.go's, security.go's,
// and databases_redis.go's four independently-written equivalents. ---
// delNegationWindow is how many characters around a forbidden-phrase match
// are searched for a negation cue.
const delNegationWindow = 60
// delNoUnnegatedMention returns an Evaluator scoring full credit unless
// forbidden matches response at some position with no negation cue nearby -
// i.e. the response recommends the forbidden thing outright rather than
// warning against it. This is deliberately not eval.NotContains, which
// would also zero out the best possible answer (one that correctly
// explains why NOT to do the forbidden thing).
func delNoUnnegatedMention(forbidden *regexp.Regexp) eval.Evaluator {
return eval.NoUnnegatedMention(forbidden, delNegationWindow, nil)
}
// delForceFlagPattern matches every "--force" mention. delForceWithLeaseSuffixPattern
// checks whether one of those matches is immediately followed by
// "-with-lease" - the safe form, exempt from the bare-force check entirely
// rather than needing a negation cue.
var delForceFlagPattern = regexp.MustCompile(`(?i)--force\b`)
var delForceWithLeaseSuffixPattern = regexp.MustCompile(`(?i)^-(with-lease|if-includes)`)
// delNoBareForcePush scores full credit unless the response recommends a
// bare "--force" push (not "--force-with-lease") with no negation cue
// nearby. A response that only ever mentions "--force-with-lease" never
// matches delForceFlagPattern's bare form (the suffix check exempts it), so
// it always scores full credit; a response that recommends bare --force
// outright scores zero.
func delNoBareForcePush() eval.Evaluator {
return eval.NoUnnegatedMention(delForceFlagPattern, delNegationWindow, delForceWithLeaseSuffixPattern)
}
// delGitBisectCommitCount is the number of candidate commits between the
// last known-good commit and the current bad HEAD in delGitBisectStepsTest.
const delGitBisectCommitCount = 137
// delGitBisectStepsWant is the worst-case number of `git bisect` steps
// needed to find the single first-bad commit among
// delGitBisectCommitCount candidates by binary search.
//
// ground truth: `git bisect` performs a binary search over the candidate
// commit range, halving the remaining range on each step until exactly one
// commit remains. The worst-case step count for N candidates is
// ceil(log2(N)): 2^7 = 128 < 137 <= 256 = 2^8, so ceil(log2(137)) = 8.
// delivery_git_test.go recomputes this with math.Ceil(math.Log2(137))
// rather than trusting the hardcoded literal alone.
//
// DC7: git's own interactive CLI, when you run `git bisect start`, prints
// a "roughly N steps" hint computed differently (closer to floor(log2(N)),
// giving "roughly 7 steps" for 137 candidates here) - a typical-case
// estimate, not a guaranteed upper bound. The prompt deliberately asks for
// the "maximum number of steps needed" to disambiguate that it wants the
// true worst case (8), not git's own displayed estimate (7).
var delGitBisectStepsWant = 8
// delGitBisectStepsTest: derive the worst-case number of `git bisect`
// binary-search steps needed to find one bad commit among N candidates.
func delGitBisectStepsTest() testkit.Test {
prompt := `A regression was introduced somewhere among the last 137
commits on main: the commit at the tip of main is bad, and the commit 137
commits back (the last tagged release) is known good. Using "git bisect"
(binary search over the candidate commits, testing one commit per step),
what is the maximum number of "git bisect" steps needed to find the exact
first commit that introduced the regression? Respond with only the integer
number of steps.`
return testkit.Test{
ID: "git-bisect-steps",
Category: "delivery",
Subcategory: "git",
Description: "Derive the worst-case git bisect step count (ceil(log2(N))) for 137 candidate commits.",
Prompt: prompt,
Eval: eval.Numeric(eval.ExtractLastNumber[int], delGitBisectStepsWant, 0),
}
}
// delGitRebaseVsMergeTest: pick rebase or merge for a solo short-lived
// branch versus a shared long-lived branch.
//
// ground truth: rebasing a branch nobody else has pulled is safe and
// produces a clean, linear, single-author history before review - exactly
// what interactive rebase is for. Rewriting history on a branch other
// people have already pulled and built work on top of forces everyone else
// to reconcile diverged history; merging (which adds a new commit rather
// than rewriting existing ones) is the only one of the two that does not
// rewrite commits other people already depend on.
func delGitRebaseVsMergeTest() testkit.Test {
prompt := `For each of these two situations, should you use "git rebase"
or "git merge" to bring in the latest changes?
scenario_a: "You are the sole author of a short-lived feature branch that
has never been pushed anywhere, and you want a clean, linear, single-author
history before opening a pull request."
scenario_b: "You are integrating a long-lived, shared release branch that
several other people have already pulled and built further commits on top
of; you must not rewrite any commit they already depend on."
Respond with only a JSON object:
{"scenario_a":"rebase"|"merge","scenario_b":"rebase"|"merge"}`
evaluator := eval.Mean(
eval.JSONField("scenario_a", "rebase"),
eval.JSONField("scenario_b", "merge"),
)
return testkit.Test{
ID: "git-rebase-vs-merge",
Category: "delivery",
Subcategory: "git",
Description: "Choose rebase for a solo unshared branch and merge for a shared branch others already depend on.",
Prompt: prompt,
Eval: evaluator,
}
}
// delGitConventionalCommits is the inline set of 6 commit messages for
// delGitConventionalCommitClassifyTest, one per standard conventional-commit
// type, grounded in this repository's own real history (agents.go's
// per-subcategory file split, ExtractLastNumber's dot-prefixed-decimal
// handling, exec.go's toolchain-missing skip behavior, and the errgroup
// dependency named in PLAN.md).
const delGitConventionalCommits = `commit1: "feat(runner): add bounded concurrency to the model x test fan-out via errgroup"
commit2: "fix(eval): correct ExtractLastNumber rejecting a standalone dot-prefixed decimal like \".9896\""
commit3: "chore(deps): bump golang.org/x/sync to v0.22.0"
commit4: "docs: document how to add a new evaluator in README.md"
commit5: "refactor(tests): split agents.go into per-subcategory files to stay under 600 lines"
commit6: "test(exec): add toolchain-missing skip cases for GoRun/PyRun/CRun"`
// delGitConventionalCommitClassifyTest: classify 6 commit messages, one per
// standard conventional-commit type, by their type prefix.
//
// ground truth (DC9): each commit message's own prefix names its
// Conventional Commits type directly - commit1="feat(...)" -> feat,
// commit2="fix(...)" -> fix, commit3="chore(...)" -> chore,
// commit4="docs: ..." -> docs, commit5="refactor(...)" -> refactor,
// commit6="test(...)" -> test. No inference beyond reading the stated
// prefix is required.
func delGitConventionalCommitClassifyTest() testkit.Test {
prompt := `Here are 6 commit messages from a project using Conventional
Commits:
` + delGitConventionalCommits + `
Classify each commit by its conventional-commit type (one of: feat, fix,
chore, docs, refactor, test). Respond with only a JSON object:
{"commit1":"...","commit2":"...","commit3":"...","commit4":"...","commit5":"...","commit6":"..."}`
evaluator := eval.Mean(
eval.JSONField("commit1", "feat"),
eval.JSONField("commit2", "fix"),
eval.JSONField("commit3", "chore"),
eval.JSONField("commit4", "docs"),
eval.JSONField("commit5", "refactor"),
eval.JSONField("commit6", "test"),
)
return testkit.Test{
ID: "git-conventional-commit-classify",
Category: "delivery",
Subcategory: "git",
Description: "Classify 6 commit messages into their conventional-commit type from the message prefix.",
Prompt: prompt,
Eval: evaluator,
}
}
// delGitWorktreeUseCaseTest: name git worktree as the mechanism for a
// second, independently checked-out working directory from the same
// repository.
//
// ground truth: "git worktree add <path> <ref>" creates a second, linked
// working directory backed by the same .git repository, checked out to a
// different ref, without stashing, committing, or cloning again - exactly
// the described need (an in-progress feature branch with uncommitted
// changes stays untouched in the main working directory while an old tag is
// built and tested elsewhere).
func delGitWorktreeUseCaseTest() testkit.Test {
prompt := `You are mid-way through an in-progress feature branch with
uncommitted changes in your main working directory. You need to build and
test the old release tag v1.4.0 at the same time, without stashing your
changes and without cloning the repository again. Name the git subcommand
that creates a second, linked working directory from the same repository
checked out to a different ref, and give the exact command to create one at
path ../v1.4.0-check checked out to tag v1.4.0.`
return testkit.Test{
ID: "git-worktree-use-case",
Category: "delivery",
Subcategory: "git",
Description: "Name git worktree as the way to check out a second ref from the same repo without stashing or re-cloning.",
Prompt: prompt,
Eval: eval.ContainsAll("git worktree add", "v1.4.0-check", "v1.4.0"),
}
}
// delGitForceWithLeaseVsForceTest: require --force-with-lease on a shared
// branch, and require any mention of bare --force to be negated.
//
// ground truth: "--force-with-lease" refuses the push if the remote branch
// has moved since your last fetch (someone else pushed), failing safely
// instead of silently overwriting their work; bare "--force" overwrites
// whatever is on the remote unconditionally, with no such check, so it must
// never be recommended unnegated for a push to a branch other people also
// push to.
func delGitForceWithLeaseVsForceTest() testkit.Test {
prompt := `Two teammates also push to the shared branch "release/2.4".
After an interactive rebase, you rewrote your local commits and must
force-push your branch. Which git push flag should you use on this shared
branch, and why must a bare "--force" push never be used there instead?`
evaluator := eval.All(
eval.W(eval.ContainsAny("--force-with-lease", "force-with-lease"), 2),
eval.W(delNoBareForcePush(), 2),
)
return testkit.Test{
ID: "git-force-with-lease-vs-force",
Category: "delivery",
Subcategory: "git",
Description: "Require --force-with-lease over bare --force for a force-push to a branch shared with teammates.",
Prompt: prompt,
Eval: evaluator,
}
}
// delGitHookChoiceTest: pick commit-msg as the hook that enforces a commit
// message format at commit time, on the developer's own machine.
//
// ground truth: commit-msg receives the path to the drafted commit message
// file and runs (and can reject it) at the moment of commit, before the
// commit is created - exactly when a message-format policy must be
// enforced. pre-commit inspects staged file contents, not the message; and
// pre-push runs too late, after commits (and any bad messages) already
// exist locally.
func delGitHookChoiceTest() testkit.Test {
prompt := `Your team wants to reject any commit whose message does not
follow the Conventional Commits format, at the moment the commit is
created, before it ever leaves the developer's machine. Name the single git
hook (its exact filename under .git/hooks/) that enforces this. Respond
with only the hook name.`
return testkit.Test{
ID: "git-hook-choice",
Category: "delivery",
Subcategory: "git",
Description: "Pick commit-msg as the hook that enforces a commit message format at commit time.",
Prompt: prompt,
Eval: eval.ExactToken("commit-msg"),
}
}
// delGitDetachedHeadRecoveryTest: recover 3 commits made in detached HEAD
// state onto a new branch, in the one order that works.
//
// ground truth: while still on the detached HEAD (pointing at the 3 new
// commits), "git branch <name>" creates a new branch ref at the current
// commit without moving HEAD, capturing the work; only then does "git
// checkout <name>" move HEAD onto that branch so it stops being detached.
// Reversing the order (checkout first) would move HEAD away from the
// detached commits before a branch ref exists to save them, losing the
// work to garbage collection.
// delGitDetachedHeadRecoveryWant lists every accepted git-subcommand-array
// answer for delGitDetachedHeadRecoveryTest (D6): the two-step "branch" then
// "checkout" sequence from the ground truth above, or either of the two
// single, atomic commands that create the branch and switch onto it in one
// step - "git checkout -b <name>" (named here by its subcommand alone,
// "checkout") or the newer "git switch -c <name>" ("switch"). Both
// single-command forms are equally safe: HEAD never sits in an
// unprotected, still-detached state between two separate commands.
var delGitDetachedHeadRecoveryWant = [][]string{
{"branch", "checkout"},
{"checkout"},
{"switch"},
}
// delGitDetachedHeadRecoveryEval scores full credit when the response's
// JSON string array exactly matches any one of
// delGitDetachedHeadRecoveryWant's accepted sequences (D6).
func delGitDetachedHeadRecoveryEval() eval.Evaluator {
return eval.EvaluatorFunc(func(ctx context.Context, response string) eval.Score {
for _, want := range delGitDetachedHeadRecoveryWant {
if eval.JSONStringArrayEquals(want).Evaluate(ctx, response).Value == 1 {
return eval.Score{Value: 1, Detail: fmt.Sprintf("matches accepted sequence %v", want)}
}
}
return eval.Score{Value: 0, Detail: fmt.Sprintf("does not match any accepted sequence %v", delGitDetachedHeadRecoveryWant)}
})
}
func delGitDetachedHeadRecoveryTest() testkit.Test {
prompt := `You ran "git checkout <old-commit-sha>" directly (not a
branch name), then made 3 new commits while HEAD was detached. You only now
realize you need to keep that work, and HEAD is still detached. Give the
ordered list of git subcommands (just the subcommand names, e.g. "branch")
you must run, in order, to save the 3 commits onto a new branch AND end up
checked out on that branch, with HEAD attached to it (a bare "git branch"
alone saves the commits but leaves HEAD detached, which is not enough
here). Respond with only a JSON array of subcommand names, e.g.
["a","b"].`
return testkit.Test{
ID: "git-detached-head-recovery",
Category: "delivery",
Subcategory: "git",
Description: "Order git branch then git checkout to save 3 detached-HEAD commits onto a new branch (or the single-command checkout -b / switch -c equivalent).",
Prompt: prompt,
Eval: delGitDetachedHeadRecoveryEval(),
}
}
// delGitCherryPickVsRevertTest: pick revert for undoing an already-shared
// commit without rewriting public history.
//
// ground truth: "git revert" creates a new commit that undoes the target
// commit's changes, leaving all existing history (including the original
// commit) intact - safe once a commit has been pulled by other people.
// "git cherry-pick" applies an existing commit's changes onto another
// branch; it does not undo anything, so it cannot be the answer to "undo
// its effect".
func delGitCherryPickVsRevertTest() testkit.Test {
prompt := `Commit abc1234 was merged into the shared "main" branch three
days ago and has already been pulled by other teammates. You must undo its
effect without rewriting any commit history that others have already
pulled. Which single git operation do you use: cherry-pick or revert?
Respond with only one word: cherry-pick or revert.`
return testkit.Test{
ID: "git-cherry-pick-vs-revert",
Category: "delivery",
Subcategory: "git",
Description: "Pick git revert (not cherry-pick) to undo an already-shared commit without rewriting history.",
Prompt: prompt,
Eval: eval.ExactToken("revert"),
}
}
// delGitignoreFile is the inline .gitignore content for
// delGitGitignoreNegationTraceTest.
const delGitignoreFile = `*.log
build/
!build/keep.txt
docs/*.tmp
!docs/important.tmp`
// delGitignoreTreeFiles is every file path in the inline tree for
// delGitGitignoreNegationTraceTest, in prompt display order.
var delGitignoreTreeFiles = []string{
"app.go",
"debug.log",
"build/output.bin",
"build/keep.txt",
"docs/notes.tmp",
"docs/important.tmp",
"docs/readme.md",
}
// delGitignoreIgnoredWant is the set of paths from delGitignoreTreeFiles
// that git actually treats as ignored under delGitignoreFile.
//
// ground truth: "*.log" ignores debug.log. "build/" ignores the whole
// build/ directory, which ignores build/output.bin AND build/keep.txt -
// git's documented behavior is that a negation pattern cannot re-include a
// file whose parent directory is itself excluded, since git does not
// descend into an excluded directory to evaluate file-level patterns at
// all, so "!build/keep.txt" has no effect. "docs/*.tmp" ignores
// docs/notes.tmp; "!docs/important.tmp" DOES re-include docs/important.tmp,
// because docs/ itself was never excluded as a directory (only files
// matching *.tmp within it were), so git does descend into docs/ and the
// later, more specific negation pattern wins. app.go and docs/readme.md
// match no pattern. delivery_git_test.go verifies this exact set against a
// real throwaway git repository (git status --ignored / git check-ignore)
// rather than trusting this comment alone, skipping if git is unavailable.
var delGitignoreIgnoredWant = []string{
"build/keep.txt",
"build/output.bin",
"debug.log",
"docs/notes.tmp",
}
// delGitGitignoreNegationTraceTest: trace which files a .gitignore with
// mixed exclude/negate rules actually ignores, including the
// excluded-parent-directory negation trap.
func delGitGitignoreNegationTraceTest() testkit.Test {
prompt := `Here is a repository's .gitignore:
` + "```gitignore\n" + delGitignoreFile + "\n```" + `
And here is the full list of files that exist in the working tree:
app.go, debug.log, build/output.bin, build/keep.txt, docs/notes.tmp,
docs/important.tmp, docs/readme.md
Which of these files does git actually treat as ignored? Respond with only
a JSON array of the ignored file paths.`
return testkit.Test{
ID: "git-gitignore-negation-trace",
Category: "delivery",
Subcategory: "git",
Description: "Trace which files a .gitignore with mixed exclude/negate rules ignores, including the excluded-parent-directory negation trap.",
Prompt: prompt,
Eval: eval.JSONStringSet(delGitignoreIgnoredWant),
}
}
// delGitStashPopConflictTest: state whether a conflicting `git stash pop`
// drops the stash entry.
//
// ground truth: "git stash pop" only drops the stash entry after a clean
// apply. On a conflict it leaves the working tree with conflict markers AND
// explicitly keeps the stash entry in the stash list (git itself prints
// "The stash entry is kept in case you need it again"), so no work is lost;
// the user must resolve the conflict and then run "git stash drop"
// manually. delivery_git_test.go verifies this against a real throwaway git
// repository by staging a genuine stash/commit conflict and checking `git
// stash list` afterward, skipping if git is unavailable.
func delGitStashPopConflictTest() testkit.Test {
prompt := `You run "git stash push" to save uncommitted changes to a
file, then make and commit a new change to that same file, and later run
"git stash pop". The stashed change conflicts with your new commit on that
file. Immediately after "git stash pop" reports the conflict, is the stash
entry dropped from the stash list, or kept? Respond with only one word:
dropped or kept.`
return testkit.Test{
ID: "git-stash-pop-conflict",
Category: "delivery",
Subcategory: "git",
Description: "State that a conflicting git stash pop keeps the stash entry rather than dropping it.",
Prompt: prompt,
Eval: eval.ExactToken("kept"),
}
}