Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,24 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
# The gate measures a RANGE, so it needs the history the range lives
# in. A shallow clone has neither the tags nor the base commit, and
# `git describe` would answer nothing -- which the gate reports as an
# unset base rather than silently measuring the whole repository.
fetch-depth: 0

- name: Install Devbox
uses: jetify-com/devbox-install-action@v0.15.0

# The CHANGED gate, not the repository-sized one. TestMutation asks for 736
# mutants and dies at its thirty minutes having reached about 424, measured
# four times; both levers are spent. Backlog entry 21's recorded answer is
# that ditto's own answer to a repository-sized bill is to mutate what the
# change touched, and this is that answer wired up.
#
# `make test.mutation` is still there, and workflow_dispatch still reaches
# it, because the repository-sized question is worth asking on purpose --
# just not on every push, against a clock it cannot beat.
- name: "🧬 Mutation Tests"
run: devbox run -- make test.mutation
run: devbox run -- make test.mutation.changed
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,47 @@ All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.9.0] - 2026-08-30

The release that makes ditto's own gate finish, by making it ask a question it
can afford.

### Added

- **`ditto changed --since <ref>`**, and `PlanChanged` / `RunChanged` behind it.
It is `staged` asked of a committed range instead of the index: the scope is
`<ref>...HEAD`, the diff against their merge base, so a base that has moved on
does not drag somebody else's commits into the bill. The diff parsing, the byte
offsets, the fail-open rule and the sandbox are the staged path's, unchanged.

It exists because `ditto staged` cannot be a CI gate. A CI checkout has nothing
staged — the change is already committed — so a gate pointed at the staged
scope skips, reports success, and measures nothing.

It refuses a checkout with uncommitted work in it. A range scope names bytes of
`HEAD` while the sandbox is written from the index, and those are the same tree
only while nothing is modified or staged; scoping against one and mutating the
other is the defect already measured at seven of eight verdicts moving.

There is no default base, and there will not be one: on a CI checkout the
useful base is the last release, on a branch it is the trunk, and a base
guessed wrong is either a bill nobody asked for or a scope of nothing reported
as green.

### Changed

- **ditto's own CI gate mutates the change rather than the repository.** The
repository-sized run asks for 783 mutants and dies at its thirty minutes having
reached about 424, measured four times. Both levers were already spent: gating
removes 54% of the compilations and does not close it, and cutting the mutant's
suite by 46% moved the gate by 0.5%, because `-failfast` already stops a killed
mutant at its first failing test. The bill was the wrong size rather than badly
paid — backlog entry 21, open since the measurement and now closed.

`make test.mutation` is untouched and `workflow_dispatch` still reaches it. The
repository-sized question is worth asking on purpose; it was being asked on
every push, against a clock it could not beat.

## [0.8.0] - 2026-08-30

A release about what a run SAYS. Every item came from one exchange with a
Expand Down Expand Up @@ -627,6 +668,7 @@ here, not yet built.
- The `retract` block. It named published versions of the upstream module path,
which do not exist under this one.

[0.9.0]: https://github.com/Disble/ditto/releases/tag/v0.9.0
[0.8.0]: https://github.com/Disble/ditto/releases/tag/v0.8.0
[0.7.0]: https://github.com/Disble/ditto/releases/tag/v0.7.0
[0.6.0]: https://github.com/Disble/ditto/releases/tag/v0.6.0
Expand Down
80 changes: 80 additions & 0 deletions changed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package ditto

import (
"fmt"

"github.com/Disble/ditto/internal/staged"
)

// PlanChanged answers what a committed change justifies, and changes nothing.
//
// It is PlanStaged's question asked of a range instead of the index, and it
// exists because a gate cannot ask the index anything. On a CI checkout nothing
// is staged: a run pointed at the staged scope skips, reports success, and
// measures nothing — which is the one result this repository refuses to call
// green. The change is still there. It is just already committed.
//
// The scope is `base...HEAD`, the diff against their merge base, so a base that
// has moved on since the change was written does not drag somebody else's
// commits into the bill.
func PlanChanged(directory, baseRef string, excludePrefixes []string) (StagedPlan, error) {
repository, err := staged.New(staged.OSRunner{}, directory)
if err != nil {
return StagedPlan{}, fmt.Errorf("reading the repository: %w", err)
}

files, err := repository.ChangedFiles(baseRef, excludePrefixes)
if err != nil {
return StagedPlan{}, fmt.Errorf("reading the changed files: %w", err)
}

plan := StagedPlan{Root: repository.Root(), Files: files, Ranges: map[string][]Range{}}
if len(files) == 0 {
return plan, nil
}

scope, err := repository.ChangedScopeOf(baseRef, files)
if err != nil {
return StagedPlan{}, fmt.Errorf("reading the changed scope: %w", err)
}

plan.Ranges = rangesFrom(scope.Ranges)
plan.Derived = scope.Derived
plan.Reason = scope.Reason

return plan, nil
}

// RunChanged mutates exactly what a committed change justifies.
//
// It refuses a checkout with uncommitted work in it, and that refusal is the
// whole safety of the thing. The sandbox is written from the INDEX and a range
// scope names bytes of HEAD; those are the same tree only while nothing is
// modified or staged. Scoping against one tree and mutating another is the
// defect already measured on a fixture built for it — seven of eight verdicts
// moved — and it is silent, which is why this stops rather than warns.
//
// Everything below the scope is the staged path unchanged: the same sandbox, the
// same `.ditto.json` for what git does not carry, the same notice when the diff
// could not be turned into ranges.
func RunChanged(directory, baseRef string, excludePrefixes []string, options ...Option) error {
plan, err := PlanChanged(directory, baseRef, excludePrefixes)
if err != nil {
return err
}

if !plan.Mutable() {
return nil
}

repository, err := staged.New(staged.OSRunner{}, directory)
if err != nil {
return fmt.Errorf("reading the repository: %w", err)
}

if err := repository.RequireClean(); err != nil {
return fmt.Errorf("checking the checkout: %w", err)
}

return runInSandbox(directory, plan, options)
}
72 changes: 72 additions & 0 deletions changed_mutation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//go:build mutation

package ditto_test

import (
"os"
"testing"

"github.com/Disble/ditto"
)

// baseRefVariable names the ref the gate measures against.
//
// There is no default that is right everywhere: on a CI checkout the useful base
// is the last release, on a branch it is the trunk, and guessing wrong is either
// a bill nobody asked for or a scope of nothing. So it is named, and the gate
// says plainly when it was not.
const baseRefVariable = "DITTO_GATE_BASE"

// TestChangedMutation is the gate that finishes.
//
// TestMutation asks the repository-sized question — 736 mutants — and dies at
// its thirty minutes having reached about 424 of them, measured four times now.
// Both levers are spent: gating removes 54% of the compilations and does not
// close it, and cutting the suite the mutant is judged by, by 46%, moved the
// gate by 0.5% because `-failfast` already stops a killed mutant at its first
// failing test. The bill is the wrong SIZE rather than badly paid, and backlog
// entry 21 wrote down the answer without building it: ditto's own answer to a
// repository-sized bill is to mutate what the change touched.
//
// TestStagedMutation cannot be that gate. It reads the index, and on a CI
// checkout nothing is staged — so it skips, reports success, and measures
// nothing. That is the shape of failure this repository refuses, and it is why
// the range scope had to exist before the gate could move.
func TestChangedMutation(t *testing.T) {
base := os.Getenv(baseRefVariable)
if base == "" {
t.Skipf("set %s to the ref this change is measured against, for example a release tag", baseRefVariable)
}

plan, err := ditto.PlanChanged(".", base, []string{"testdata/"})
if err != nil {
t.Fatalf("reading the change since %s: %v", base, err)
}

// Said whether or not there is anything to do, because a gate that reports
// success has to say what it measured. "Nothing changed" and "the scope was
// never read" produce the same exit code and are not the same result.
t.Logf("scope since %s: %d file(s), %d with byte ranges", base, len(plan.Files), len(plan.Ranges))

if !plan.Mutable() {
t.Skipf("nothing changed since %s is worth mutating", base)
}

for _, file := range plan.Files {
t.Logf(" %s: %d range(s)", file, len(plan.Ranges[file]))
}

if notice := plan.ScopeNotice(); notice != "" {
t.Log(notice)
}

if err := ditto.RunChanged(".", base, []string{"testdata/"},
ditto.ForceColors(),
ditto.WithTestCommand(makeCommand(t)+" test.failfast MAKEFLAGS="),
ditto.WithMinimumThreshold(0.5),
ditto.Parallel(),
ditto.Gated(),
); err != nil {
t.Fatal(err)
}
}
146 changes: 146 additions & 0 deletions changed_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package ditto_test

import (
"strings"
"testing"

"github.com/Disble/ditto"
"github.com/Disble/ditto/internal/dittotesting"
)

// These exercise the answers PlanChanged and RunChanged give when something is
// wrong, which is most of what they are: every branch below was a surviving
// mutant on ditto's own gate, because nothing had ever made those errors happen.
//
// They use a real repository rather than a double. The whole subject is what git
// says, and a fake that agrees with my reading of git proves my reading rather
// than the behaviour.

func TestPlanChangedReadsACommittedChange(t *testing.T) {
dir := dittotesting.GitRepository(t)

dittotesting.WriteFile(t, dir, "added.go", "package fixture\n\nfunc Added(a, b int) bool { return a > b }\n")
dittotesting.Git(t, dir, "add", "-A")
dittotesting.Git(t, dir, "commit", "-m", "add")

plan, err := ditto.PlanChanged(dir, "base", nil)
if err != nil {
t.Fatalf("planning: %v", err)
}

if !plan.Mutable() {
t.Fatal("a committed Go change was not worth mutating")
}

if len(plan.Files) != 1 || plan.Files[0] != "added.go" {
t.Fatalf("files = %v, want only added.go", plan.Files)
}

if !plan.Derived {
t.Fatalf("the scope fell open to whole files: %s", plan.Reason)
}
}

// A commit that changes no Go source is not a failure. It is a scope of nothing,
// and saying so is what lets a gate skip honestly rather than report a green it
// did not earn.
func TestPlanChangedIsEmptyWhenNoGoSourceMoved(t *testing.T) {
dir := dittotesting.GitRepository(t)

dittotesting.WriteFile(t, dir, "readme.md", "# fixture\n")
dittotesting.Git(t, dir, "add", "-A")
dittotesting.Git(t, dir, "commit", "-m", "docs")

plan, err := ditto.PlanChanged(dir, "base", nil)
if err != nil {
t.Fatalf("planning: %v", err)
}

if plan.Mutable() {
t.Fatalf("a docs-only commit was reported as mutable: %v", plan.Files)
}
}

func TestPlanChangedExcludesByPrefix(t *testing.T) {
dir := dittotesting.GitRepository(t)

dittotesting.WriteFile(t, dir, "tools/tool.go", "package tools\n\nfunc Tool(a, b int) bool { return a > b }\n")
dittotesting.Git(t, dir, "add", "-A")
dittotesting.Git(t, dir, "commit", "-m", "tool")

plan, err := ditto.PlanChanged(dir, "base", []string{"tools/"})
if err != nil {
t.Fatalf("planning: %v", err)
}

if plan.Mutable() {
t.Fatalf("an excluded prefix was still planned: %v", plan.Files)
}
}

// A base that does not exist is an error rather than an empty scope. The two are
// the same exit code and opposite meanings: one is a change with nothing in it,
// the other is a question git could not answer.
func TestPlanChangedRefusesAnUnknownBase(t *testing.T) {
_, err := ditto.PlanChanged(dittotesting.GitRepository(t), "no-such-ref", nil)
if err == nil {
t.Fatal("an unknown base was accepted")
}

if !strings.Contains(err.Error(), "no-such-ref") {
t.Fatalf("the error does not name the base: %v", err)
}
}

func TestPlanChangedRefusesSomewhereThatIsNotARepository(t *testing.T) {
if _, err := ditto.PlanChanged(t.TempDir(), "base", nil); err == nil {
t.Fatal("a directory outside any repository was accepted")
}
}

// RunChanged refuses a dirty checkout, and that refusal is the whole safety of
// reusing the index-backed sandbox: a range scope names bytes of HEAD, and those
// are the same bytes only while nothing is modified or staged.
func TestRunChangedRefusesADirtyCheckout(t *testing.T) {
dir := dittotesting.GitRepository(t)

dittotesting.WriteFile(t, dir, "added.go", "package fixture\n\nfunc Added(a, b int) bool { return a > b }\n")
dittotesting.Git(t, dir, "add", "-A")
dittotesting.Git(t, dir, "commit", "-m", "add")
dittotesting.WriteFile(t, dir, "kept.go", "package fixture\n\nfunc Kept() int { return 2 }\n")

err := ditto.RunChanged(dir, "base", nil)
if err == nil {
t.Fatal("a dirty checkout was accepted")
}

if !strings.Contains(err.Error(), "kept.go") {
t.Fatalf("the refusal does not name what is dirty: %v", err)
}
}

// Nothing to mutate is nothing to do, and it is not an error. A gate that
// treated it as one would fail every docs-only commit.
func TestRunChangedDoesNothingWhenNothingChanged(t *testing.T) {
dir := dittotesting.GitRepository(t)

dittotesting.WriteFile(t, dir, "readme.md", "# fixture\n")
dittotesting.Git(t, dir, "add", "-A")
dittotesting.Git(t, dir, "commit", "-m", "docs")

if err := ditto.RunChanged(dir, "base", nil); err != nil {
t.Fatalf("a docs-only commit was reported as a failure: %v", err)
}
}

func TestRunChangedRefusesAnUnknownBase(t *testing.T) {
if err := ditto.RunChanged(dittotesting.GitRepository(t), "no-such-ref", nil); err == nil {
t.Fatal("an unknown base was accepted")
}
}

func TestRunChangedRefusesSomewhereThatIsNotARepository(t *testing.T) {
if err := ditto.RunChanged(t.TempDir(), "base", nil); err == nil {
t.Fatal("a directory outside any repository was accepted")
}
}
Loading
Loading