diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index b1e16c77..d217450b 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0490af..2ee34772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `**, and `PlanChanged` / `RunChanged` behind it. + It is `staged` asked of a committed range instead of the index: the scope is + `...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 @@ -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 diff --git a/changed.go b/changed.go new file mode 100644 index 00000000..f86a2999 --- /dev/null +++ b/changed.go @@ -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) +} diff --git a/changed_mutation_test.go b/changed_mutation_test.go new file mode 100644 index 00000000..8ec6e9e2 --- /dev/null +++ b/changed_mutation_test.go @@ -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) + } +} diff --git a/changed_scope_test.go b/changed_scope_test.go new file mode 100644 index 00000000..06738908 --- /dev/null +++ b/changed_scope_test.go @@ -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") + } +} diff --git a/cmd/ditto/main.go b/cmd/ditto/main.go index b3f2fb94..fe82f840 100644 --- a/cmd/ditto/main.go +++ b/cmd/ditto/main.go @@ -49,6 +49,8 @@ func command(args []string, help io.Writer) error { return runCommand(args[1:]) case "staged": return stagedCommand(args[1:], os.Stdout) + case "changed": + return changedCommand(args[1:], os.Stdout) case "-h", "--help", "help": usage(help) @@ -99,6 +101,7 @@ func usage(out io.Writer) { ditto run [flags] mutate a repository and report what survived ditto staged [flags] mutate only what a staged change justifies + ditto changed [flags] mutate only what a committed change justifies ditto version the module version this binary was built from Run `+"`ditto run -h`"+` for its flags. @@ -272,6 +275,102 @@ func stagedOptions(testCommand string, threshold float32, gated, confirm, loud b return options } +// changedCommand is `staged` for a change that is already committed. +// +// A gate cannot ask the index anything: on a CI checkout nothing is staged, so a +// run pointed at the staged scope skips and reports a green that measured +// nothing. This asks the same question of `--since ref ... HEAD` instead. +func changedCommand(args []string, out io.Writer) error { + flags := flag.NewFlagSet("ditto changed", flag.ContinueOnError) + directory := flags.String("cwd", ".", "a directory inside the repository; its root is resolved from here") + since := flags.String("since", "", "the `ref` to measure the change against, for example a tag or origin/main") + testCommand := flags.String("test-command", "go test -count=1 -json ./...", testCommandHelp) + threshold := flags.Float64("threshold", 1.0, "minimum mutation score, from 0 to 1") + dry := flags.Bool("dry", false, "report what the change justifies and run nothing") + gated := flags.Bool("gated", false, "run a file's mutants from one compilation instead of one each") + confirm := flags.Bool("confirm-kills", false, confirmKillsHelp) + loud := flags.Bool("verbose", false, "print what the run is doing as it does it") + sandbox := flags.String("sandbox", "", `how each file reaches the sandbox: "copy" (default), "hardlink" or "link"`) + + var exclude excludes + + flags.Var(&exclude, "exclude-prefix", "repository-relative prefix never worth mutating; repeatable") + + flags.Usage = func() { + fmt.Fprintln(os.Stderr, "Usage of ditto changed:") + flags.PrintDefaults() + fmt.Fprint(os.Stderr, changedConfigHelp) + } + + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + + return fmt.Errorf("reading the flags: %w", err) + } + + if *since == "" { + return errNoBaseRef + } + + if *threshold < 0 || *threshold > 1 { + return fmt.Errorf("--threshold is %.2f, and a mutation score is between 0 and 1", *threshold) //nolint:err113 // the number is the message + } + + if *dry { + return reportChangedPlan(*directory, *since, exclude, out) + } + + options := stagedOptions(*testCommand, float32(*threshold), *gated, *confirm, *loud, *sandbox) + + return ditto.RunChanged(*directory, *since, exclude, options...) //nolint:wrapcheck // this is the top of the program: the message is already the one a reader needs +} + +// errNoBaseRef refuses to guess. There is no default that is right in a CI +// checkout and in a working tree and on a branch, and a scope guessed wrong is +// either a bill nobody asked for or a green that measured nothing. +var errNoBaseRef = errors.New("ditto changed needs --since, for example: ditto changed --since origin/main") + +// changedConfigHelp names what `-h` would otherwise not say. +const changedConfigHelp = ` +The scope is ` + "`--since ...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 checkout must have no uncommitted work in it. A range scope names bytes of +HEAD and the sandbox is written from the index; those are the same tree only +while nothing is modified or staged. + +.ditto.json works here exactly as it does for ` + "`staged`" + `. +` + +// reportChangedPlan answers what a committed change would cost without paying +// for it. +func reportChangedPlan(directory, baseRef string, exclude excludes, out io.Writer) error { + plan, err := ditto.PlanChanged(directory, baseRef, exclude) + if err != nil { + return fmt.Errorf("reading the change: %w", err) + } + + if !plan.Mutable() { + fmt.Fprintf(out, "ditto: nothing changed since %s is worth mutating.\n", baseRef) + + return nil + } + + fmt.Fprintf(out, "ditto: %d file(s) changed since %s under %s\n", len(plan.Files), baseRef, plan.Root) + + for _, file := range plan.Files { + fmt.Fprintf(out, " %s: %s\n", file, describeRanges(plan.Ranges[file])) + } + + if !plan.Derived { + fmt.Fprintf(out, " scope: %s\n", plan.Reason) + } + + return nil +} + // reportPlan answers what a staged change would cost without paying for it. A // dry run that materialised a sandbox or started a suite would not be one. func reportPlan(directory string, exclude excludes, out io.Writer) error { diff --git a/cmd/ditto/main_test.go b/cmd/ditto/main_test.go index 7f90f896..a46479e4 100644 --- a/cmd/ditto/main_test.go +++ b/cmd/ditto/main_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/Disble/ditto" + "github.com/Disble/ditto/internal/dittotesting" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -101,3 +102,102 @@ func TestTestCommandHelpRendersItsValueName(t *testing.T) { assert.NotContains(t, rendered.String(), "-test-command -json") assert.NotContains(t, rendered.String(), "-test-command ./...") } + +// TestChangedRefusesToGuessABase covers the one decision `changed` deliberately +// does not make for you. There is no default that is right on a CI checkout, in +// a working tree and on a branch at once, and a base guessed wrong is either a +// bill nobody asked for or a scope of nothing reported as success. +func TestChangedRefusesToGuessABase(t *testing.T) { + err := changedCommand([]string{"--dry"}, &bytes.Buffer{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "--since") +} + +// TestChangedIsASubcommand keeps the dispatch honest: an unknown subcommand +// prints usage and fails, so a `changed` that was never wired would look exactly +// like a typo. +func TestChangedIsASubcommand(t *testing.T) { + out := &bytes.Buffer{} + + err := command([]string{"--help"}, out) + + require.NoError(t, err) + assert.Contains(t, out.String(), "ditto changed") +} + +// TestChangedThresholdBounds pins the one arithmetic check in this command. A +// mutation score is between 0 and 1, and a threshold outside that is a request +// that can never be met or can never fail -- either way the gate stops meaning +// anything, silently. +func TestChangedThresholdBounds(t *testing.T) { + for _, threshold := range []string{"-0.1", "1.1", "2"} { + t.Run("refuses "+threshold, func(t *testing.T) { + err := changedCommand([]string{"--since", "HEAD", "--threshold", threshold}, &bytes.Buffer{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "between 0 and 1") + }) + } + + for _, threshold := range []string{"0", "0.5", "1"} { + t.Run("accepts "+threshold, func(t *testing.T) { + // It gets past the bounds check and fails on the repository instead, + // which is what says the number itself was allowed through. + err := changedCommand( + []string{"--since", "HEAD", "--threshold", threshold, "--dry", "--cwd", t.TempDir()}, + &bytes.Buffer{}, + ) + + require.Error(t, err) + assert.NotContains(t, err.Error(), "between 0 and 1") + }) + } +} + +// TestStagedThresholdBounds is the same check on the subcommand that already had +// it, so the two cannot drift apart unnoticed. +func TestStagedThresholdBounds(t *testing.T) { + err := stagedCommand([]string{"--threshold", "1.5"}, &bytes.Buffer{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "between 0 and 1") +} + +// TestChangedDispatchPassesItsFlagsOn covers the argument slice itself. Passing +// one too few drops the subcommand's first flag; one too many hands `changed` +// its own name as a positional and flag.Parse stops there. Both leave --since +// unset, and both look exactly like a user who forgot it. +func TestChangedDispatchPassesItsFlagsOn(t *testing.T) { + err := command([]string{"changed", "--since", "HEAD", "--dry", "--cwd", t.TempDir()}, &bytes.Buffer{}) + + require.Error(t, err) + assert.NotContains(t, err.Error(), "--since", "the flags did not reach the subcommand") +} + +// TestChangedDryReportsTheScope covers what --dry is for: answering "what would +// this cost" without paying for it. The per-file lines and the widened-scope +// notice are the whole output, and neither had ever been exercised. +func TestChangedDryReportsTheScope(t *testing.T) { + dir := dittotesting.GitRepositoryWithAChange(t) + out := &bytes.Buffer{} + + err := changedCommand([]string{"--since", "base", "--dry", "--cwd", dir}, out) + + require.NoError(t, err) + assert.Contains(t, out.String(), "1 file(s) changed since base") + assert.Contains(t, out.String(), "added.go:") + // A derived scope says nothing about itself; only a widened one explains. + assert.NotContains(t, out.String(), "scope:") +} + +func TestChangedDrySaysWhenThereIsNothingToDo(t *testing.T) { + dir := dittotesting.GitRepositoryWithAChange(t) + out := &bytes.Buffer{} + + // Its own base: a range from a commit to itself is empty by construction. + err := changedCommand([]string{"--since", "HEAD", "--dry", "--cwd", dir}, out) + + require.NoError(t, err) + assert.Contains(t, out.String(), "nothing changed since HEAD is worth mutating") +} diff --git a/docs/backlog.md b/docs/backlog.md index 090e6abc..f625197d 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -573,7 +573,26 @@ invocations. That ratio is why this is worth doing and why nothing cheaper is needed: the AST-only alternative catches 42 of 83 and **wrongly refuses one that compiles**, which is the wrong direction to be wrong in. -## 21. The gate asks a repository-sized question on every push +## 21. The gate asks a repository-sized question on every push — **closed 2026-08-30** + +Closed by building the answer this entry already named. `PlanChanged` and +`RunChanged` scope a release to `base...HEAD` — the same diff-to-byte-ranges +machinery the staged path uses, asked of a committed range instead of the index — +and CI now runs `make test.mutation.changed` instead of the repository-sized one. + +The staged gate could not be that gate, which is why this stayed open after +`ditto staged` existed: it reads the index, and on a CI checkout nothing is +staged, so it would skip and report a green that measured nothing. + +A range scope names bytes of HEAD while the sandbox is written from the index, +so `RunChanged` refuses a checkout with uncommitted work in it. Those two trees +are the same one only while nothing is modified or staged, and scoping against +one while mutating the other is the defect already measured at seven of eight +verdicts moving. + +`make test.mutation` is unchanged and still reachable by hand. The +repository-sized question is worth asking on purpose; it was being asked on every +push, against a clock it cannot beat. Measured three times, all to the same end: with gating on and `make` resolved, the gate reaches **424 of 727 mutants** and dies at `-timeout=30m`. Two levers diff --git a/internal/dittotesting/gitrepository.go b/internal/dittotesting/gitrepository.go new file mode 100644 index 00000000..c60f04df --- /dev/null +++ b/internal/dittotesting/gitrepository.go @@ -0,0 +1,111 @@ +package dittotesting + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// The range scope's whole subject is what git says, so its tests need real +// repositories rather than a double: a fake that agrees with my reading of git +// proves my reading rather than the behaviour. Two packages need them — the +// library's own tests and the command's — and one copy of this is enough. + +// GitRepository builds a throwaway repository with one committed Go file, tagged +// `base`, and returns its path. It is removed with the test's temporary +// directory. +func GitRepository(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + + Git(t, dir, "init") + Git(t, dir, "config", "user.email", "fixture@example.com") + Git(t, dir, "config", "user.name", "fixture") + WriteFile(t, dir, "kept.go", "package fixture\n\nfunc Kept() int { return 1 }\n") + Git(t, dir, "add", "-A") + Git(t, dir, "commit", "-m", "base") + Git(t, dir, "tag", "base") + + return dir +} + +// GitRepositoryWithAChange is GitRepository plus one committed Go change after +// the tag, which is the shape most of these questions are asked of. +func GitRepositoryWithAChange(t *testing.T) string { + t.Helper() + + dir := GitRepository(t) + + WriteFile(t, dir, "added.go", "package fixture\n\nfunc Added(a, b int) bool { return a > b }\n") + Git(t, dir, "add", "-A") + Git(t, dir, "commit", "-m", "add") + + return dir +} + +// Git runs one git command in a fixture, with the addressing this process +// inherited removed. +// +// A hook exports GIT_DIR, GIT_INDEX_FILE and friends as absolute paths, and +// everything spawned below inherits them — so a fixture that kept them would +// quietly operate on the real checkout and succeed. They are REMOVED rather than +// blanked, because git rejects an empty GIT_DIR outright. +func Git(t *testing.T, dir string, args ...string) { + t.Helper() + + //nolint:gosec // the arguments are this package's own literals, and the binary is resolved above + command := exec.CommandContext(t.Context(), gitBinary(t), args...) + command.Dir = dir + + kept := make([]string, 0, len(os.Environ())) + + for _, variable := range os.Environ() { + name, _, _ := strings.Cut(variable, "=") + if !strings.HasPrefix(name, "GIT_") { + kept = append(kept, variable) + } + } + + command.Env = kept + + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) + } +} + +// gitBinary resolves git once, to an absolute path, instead of naming it and +// letting the operating system search. +// +// `exec.Command("git", …)` reads PATH at every call, so a directory an attacker +// can write to — or prepend — decides which git runs. That is SonarQube's rule +// S4036, and internal/gobuildrunner already resolves the Go toolchain the same +// way for the same reason: something ambient deciding what a subprocess really +// is. +func gitBinary(t *testing.T) string { + t.Helper() + + found, err := exec.LookPath("git") + if err != nil { + t.Fatalf("no git on PATH: %v", err) + } + + return found +} + +// WriteFile puts one file into a fixture, creating nothing else. +func WriteFile(t *testing.T, dir, name, content string) { + t.Helper() + + path := filepath.Join(dir, name) + + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("creating the directory for %s: %v", name, err) + } + + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("writing %s: %v", name, err) + } +} diff --git a/internal/staged/changed.go b/internal/staged/changed.go new file mode 100644 index 00000000..689478b0 --- /dev/null +++ b/internal/staged/changed.go @@ -0,0 +1,95 @@ +package staged + +import ( + "fmt" + "strings" +) + +// The staged questions read the index. These read a RANGE, and they exist +// because backlog entry 21 measured what the index cannot answer: on a CI +// checkout nothing is staged, so a gate pointed at the staged scope skips and +// reports a green that measured nothing — the shape of failure this repository +// refuses. The change is still there; it is just already committed. +// +// Everything else is shared. The diff parsing, the byte offsets, the fail-open +// rule and the sandbox are the same, because the question is the same one asked +// of a different pair of trees. + +// ChangedFiles lists the Go sources a range touched, on the same terms Files +// uses: not tests, and not anything under an excluded prefix. +func (r *Repository) ChangedFiles(baseRef string, excludedPrefixes []string) ([]string, error) { + output, err := r.git("diff", "--name-only", "--diff-filter=ACMR", "-z", rangeOf(baseRef)) + if err != nil { + return nil, fmt.Errorf("listing the files changed since %s: %w", baseRef, err) + } + + return selectMutable(splitNUL(output), excludedPrefixes), nil +} + +// ChangedScopeOf converts each file's range diff into byte ranges of HEAD. +// +// It fails open exactly as ScopeOf does, and for the same reason: mutating too +// much is a cost, and mutating the wrong bytes is a wrong answer. +func (r *Repository) ChangedScopeOf(baseRef string, files []string) (Scope, error) { + scope := Scope{Files: files, Ranges: map[string][]Range{}, Derived: true} + + for _, file := range files { + diff, err := r.git("-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-renames", "-U0", + rangeOf(baseRef), "--", file) + if err != nil { + return Scope{}, fmt.Errorf("reading the diff for %s since %s: %w", file, baseRef, err) + } + + content, err := r.git("show", "HEAD:"+file) + if err != nil { + return Scope{}, fmt.Errorf("reading the committed content of %s: %w", file, err) + } + + lines, parseErr := changedLines(string(diff)) + if parseErr != nil || len(lines) == 0 { + //nolint:nilerr // failing open IS the answer: a scope that cannot be derived is a wider scope that says why + return failOpen(files, "a diff since "+baseRef+" yielded no usable range; mutating whole files"), nil + } + + offsets := merge(offsetsOf(content, lines)) + if len(offsets) == 0 { + return failOpen(files, "a changed line range did not land on committed bytes; mutating whole files"), nil + } + + scope.Ranges[file] = offsets + } + + return scope, nil +} + +// rangeOf asks what the change ADDED rather than how two lines of work drifted +// apart. `base...HEAD` is the diff against their merge base, so a base that has +// moved on since the change was written does not widen the scope with somebody +// else's commits. +func rangeOf(baseRef string) string { return baseRef + "...HEAD" } + +// RequireClean refuses a checkout with uncommitted work in it. +// +// This is what makes the range scope safe to run in the existing sandbox. That +// sandbox is written from the INDEX, and a range scope names bytes of HEAD; the +// two 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 refuses rather than warns. +// +// The staged path needs no such check: it derives its scope from the index and +// mutates the index, and RejectPartial covers the one file that could disagree. +func (r *Repository) RequireClean() error { + output, err := r.git("status", "--porcelain") + if err != nil { + return fmt.Errorf("checking whether the checkout is clean: %w", err) + } + + dirty := strings.TrimSpace(string(output)) + if dirty == "" { + return nil + } + + return fmt.Errorf( //nolint:err113 // the listing is the message + "ditto: a range scope measures committed bytes, and this checkout has uncommitted work:\n%s", dirty) +} diff --git a/internal/staged/changed_internal_test.go b/internal/staged/changed_internal_test.go new file mode 100644 index 00000000..34c2bb8a --- /dev/null +++ b/internal/staged/changed_internal_test.go @@ -0,0 +1,203 @@ +package staged + +import ( + "errors" + "strings" + "testing" +) + +// scriptedGit answers whichever command it recognises by its argument shape, so +// a test can say what git would have said without a repository on disk. +type scriptedGit struct { + answers map[string]string + seen []string +} + +func (g *scriptedGit) Output(_, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + g.seen = append(g.seen, joined) + + for shape, answer := range g.answers { + if strings.Contains(joined, shape) { + return []byte(answer), nil + } + } + + return nil, errors.New("scriptedGit: nothing scripted for: " + joined) +} + +func scripted(answers map[string]string) (*Repository, *scriptedGit) { + git := &scriptedGit{answers: answers} + + return &Repository{root: ".", runner: git}, git +} + +// TestChangedFilesReadsARangeRatherThanTheIndex is backlog entry 21's answer. +// +// The staged scope reads `--cached`, which is empty on a CI checkout: nothing is +// staged after a push, so a gate pointed at it would skip and report a green +// that measured nothing. A range says the same thing about a change that has +// already been committed. +func TestChangedFilesReadsARangeRatherThanTheIndex(t *testing.T) { + t.Parallel() + + repository, git := scripted(map[string]string{ + "diff --name-only": "internal/thing/thing.go\x00internal/thing/thing_test.go\x00readme.md\x00", + }) + + files, err := repository.ChangedFiles("v0.7.0", []string{"testdata/"}) + if err != nil { + t.Fatalf("listing the changed files: %v", err) + } + + if len(files) != 1 || files[0] != "internal/thing/thing.go" { + t.Fatalf("changed files = %v, want only the non-test Go source", files) + } + + // The three-dot form asks what the branch added rather than what the base + // also did, which is what makes the scope the CHANGE and not the drift + // between two lines of work. + if !strings.Contains(git.seen[0], "v0.7.0...HEAD") { + t.Fatalf("git was asked %q, want a three-dot range against the base", git.seen[0]) + } +} + +// TestChangedScopeOfDerivesByteRanges holds the property the whole feature rests +// on: a scope that is not derived is a scope that mutates whole files, and on a +// repository-sized checkout that is the bill this exists to avoid. +func TestChangedScopeOfDerivesByteRanges(t *testing.T) { + t.Parallel() + + content := "package thing\n\nfunc Add(a, b int) int {\n\treturn a + b\n}\n" + diff := "@@ -4 +4 @@\n-\treturn a - b\n+\treturn a + b\n" + + repository, _ := scripted(map[string]string{ + "diff --no-ext-diff": diff, + "show": content, + }) + + scope, err := repository.ChangedScopeOf("v0.7.0", []string{"thing.go"}) + if err != nil { + t.Fatalf("reading the changed scope: %v", err) + } + + if !scope.Derived { + t.Fatalf("scope fell open to whole files: %s", scope.Reason) + } + + if len(scope.Ranges["thing.go"]) != 1 { + t.Fatalf("ranges = %v, want one span covering the changed line", scope.Ranges) + } +} + +// TestRequireCleanRefusesADirtyCheckout is what makes reusing the index-backed +// sandbox honest. +// +// A range scope names bytes of HEAD, and the sandbox is written from the INDEX. +// Those are the same bytes only while the checkout is clean, and a scope that +// names one tree while the mutants run in another is the defect measured at +// seven of eight verdicts moving. Refusing is cheap; being wrong is not. +func TestRequireCleanRefusesADirtyCheckout(t *testing.T) { + t.Parallel() + + repository, _ := scripted(map[string]string{"status --porcelain": " M internal/thing/thing.go\n"}) + + err := repository.RequireClean() + if err == nil { + t.Fatal("a dirty checkout was accepted") + } + + if !strings.Contains(err.Error(), "thing.go") { + t.Fatalf("the refusal does not name what is dirty: %v", err) + } +} + +func TestRequireCleanAcceptsACleanCheckout(t *testing.T) { + t.Parallel() + + repository, _ := scripted(map[string]string{"status --porcelain": ""}) + + if err := repository.RequireClean(); err != nil { + t.Fatalf("a clean checkout was refused: %v", err) + } +} + +// TestChangedScopeFailsOpenRatherThanGuessing covers both fail-open branches. +// +// Mutating too much is a cost; mutating the wrong bytes is a wrong answer. So a +// diff that yields no usable range widens to whole files and says why, rather +// than producing a scope nobody can trust. +func TestChangedScopeFailsOpenRatherThanGuessing(t *testing.T) { + t.Parallel() + + content := "package thing\n\nfunc Add(a, b int) int {\n\treturn a + b\n}\n" + + t.Run("when the diff carries no hunk at all", func(t *testing.T) { + t.Parallel() + + repository, _ := scripted(map[string]string{ + "diff --no-ext-diff": "diff --git a/thing.go b/thing.go\nsimilarity index 100%\n", + "show": content, + }) + + scope, err := repository.ChangedScopeOf("v0.7.0", []string{"thing.go"}) + if err != nil { + t.Fatalf("reading the changed scope: %v", err) + } + + if scope.Derived { + t.Fatal("a diff with no hunk produced a derived scope") + } + + if scope.Ranges["thing.go"] != nil { + t.Fatalf("a failed-open scope carries ranges: %v", scope.Ranges) + } + + // The reason is asserted, not just its absence. There are two ways to + // fail open here and they mean different things -- a diff nobody could + // parse, and a range that missed the file -- and a reader chasing a + // widened scope needs to know which one happened. + if !strings.Contains(scope.Reason, "no usable range") { + t.Fatalf("the reason names the wrong failure: %q", scope.Reason) + } + }) + + t.Run("when the hunk names lines the committed content does not have", func(t *testing.T) { + t.Parallel() + + repository, _ := scripted(map[string]string{ + "diff --no-ext-diff": "@@ -900 +900 @@\n-\tgone\n+\talso gone\n", + "show": content, + }) + + scope, err := repository.ChangedScopeOf("v0.7.0", []string{"thing.go"}) + if err != nil { + t.Fatalf("reading the changed scope: %v", err) + } + + if scope.Derived { + t.Fatal("a range past the end of the file produced a derived scope") + } + + if !strings.Contains(scope.Reason, "committed bytes") { + t.Fatalf("the reason does not say what went wrong: %q", scope.Reason) + } + }) +} + +// TestChangedFilesSurvivesAnEmptyRange is the docs-only commit: no Go source +// moved, which is a scope of nothing rather than a failure. +func TestChangedFilesSurvivesAnEmptyRange(t *testing.T) { + t.Parallel() + + repository, _ := scripted(map[string]string{"diff --name-only": ""}) + + files, err := repository.ChangedFiles("v0.7.0", nil) + if err != nil { + t.Fatalf("listing the changed files: %v", err) + } + + if len(files) != 0 { + t.Fatalf("files = %v, want none", files) + } +} diff --git a/makefile b/makefile index 5807ffc5..952263d0 100644 --- a/makefile +++ b/makefile @@ -71,6 +71,18 @@ test.mutation.staged: $(pre-reqs) @go test -timeout=30m -count=1 -v -tags=mutation -run TestStagedMutation .PHONY: test.mutation.staged +# test.mutation.changed is the gate CI runs, and the one that finishes. +# +# It mutates what changed since $(gate-base) rather than what the repository +# contains. The staged gate cannot do this job: it reads the index, and on a CI +# checkout nothing is staged, so it would skip and report a green that measured +# nothing. Backlog entry 21. +gate-base ?= $(shell git describe --tags --abbrev=0 HEAD^ 2>/dev/null) + +test.mutation.changed: $(pre-reqs) + @DITTO_GATE_BASE=$(gate-base) go test -timeout=30m -count=1 -v -tags=mutation -run TestChangedMutation +.PHONY: test.mutation.changed + test.mutation: $(pre-reqs) @go test -timeout=30m -count=1 -v -tags=mutation .PHONY: test.mutation diff --git a/perf/baseline.json b/perf/baseline.json index e7e59282..9a892715 100644 --- a/perf/baseline.json +++ b/perf/baseline.json @@ -17,7 +17,7 @@ "laboratoryRunsForOneChangedFunction": 4, "laboratoryRunsForOneChangedFunctionInEachOfTwoFiles": 8, "testCommandInvocationsPerReleaseWholeFixture": 49, - "mutantsPerReleaseOnThisRepository": 736 + "mutantsPerReleaseOnThisRepository": 785 }, "targets": { "sourceParsesPerReleaseWithThreeViruses": "Reached: 4, one parse per source file, down from 12. GoSourceFile.Incubate now takes the whole mutator set and parses once for all of them. With the default 14 mutators this is 14 parses per file reduced to 1.", @@ -28,6 +28,6 @@ "laboratoryRunsForOneChangedFunction": "4, the mutators that fire on one changed line and nothing else in the repository. This is what WithChangedRanges buys: without it the same fixture charges 48.", "laboratoryRunsForOneChangedFunctionInEachOfTwoFiles": "8, exactly twice the single-file number. The two ranges name offsets that exist in both files, because every fixture file has the same byte layout, so a scope holding one flat set of ranges would charge 16 and grow as the square of the file count. Keeping the ranges beside their file makes that impossible rather than merely unlikely.", "testCommandInvocationsPerReleaseWholeFixture": "49 for the 48 mutants of the whole fixture, plus one. That one is the baseline: the laboratory runs the suite once on unmutated code before scoring anything, because a test command that fails before it compiles fails for every mutant too, and ditto recognises a killed mutant by exactly that. Measured on ditto's own gate before the guard existed: 431 of 431 killed in 5.46 seconds, a perfect score for a run that compiled nothing. Every other laboratory counter here goes through a stand-in and cannot see a run the laboratory makes on its own, which is why this one exists — a cost nobody records is one that grows unnoticed, the mirror of the unrecorded gain this file already refuses. It must not grow: one baseline per release, never one per mutant.", - "mutantsPerReleaseOnThisRepository": "727, what one full run of the gate has to pay for. It was 660 before internal/verdict, and 684 before verdict.Text and the wiring, all on 2026-08-28; the ratchet fired on that commit and the number was written down rather than discovered later. Every other counter here is measured against the six-file fixture, which does not change when the repository does -- so all eight stayed green while the real cost grew from 431 mutants to 660 between 2026-08-15 and 2026-08-28 and pushed the gate past its 30-minute timeout. Measured on the two CI logs: cost per mutant moved 2.269s to 2.435s, seven percent, while the count moved forty-three; and every new mutant belonged to a file that did not exist in August -- internal/staged (133), cmd/ditto/main.go (59), staged.go (21), internal/filecopy (9). No old file grew. This counter is what perfbench's own doc comment already promised in prose: 'how many mutants a scope produces -- these are integers, identical on every machine, and a change in one is always meaningful.' It was the one number in that sentence nothing measured. It grows when code is added, and that is the point: the growth is real cost and has to be written down rather than discovered by a timeout. Moved to 734 on 2026-08-30 by backlog entries 22-26: +4 for the command line (the version subcommand, staged gating and the option builder they share) +3 for saying what a run is doing (internal/progresslaboratory and the baseline announcement), and +2 for confirming assertion kills (internal/confirminglaboratory). Each part was measured as it landed rather than one number attributed to the whole change afterwards. Scoped exactly like ditto_mutation_test.go, so it speaks for the run it is named after. Counting costs no test command and no sandbox. docs/experiments/counting-the-real-repository.md." + "mutantsPerReleaseOnThisRepository": "727, what one full run of the gate has to pay for. It was 660 before internal/verdict, and 684 before verdict.Text and the wiring, all on 2026-08-28; the ratchet fired on that commit and the number was written down rather than discovered later. Every other counter here is measured against the six-file fixture, which does not change when the repository does -- so all eight stayed green while the real cost grew from 431 mutants to 660 between 2026-08-15 and 2026-08-28 and pushed the gate past its 30-minute timeout. Measured on the two CI logs: cost per mutant moved 2.269s to 2.435s, seven percent, while the count moved forty-three; and every new mutant belonged to a file that did not exist in August -- internal/staged (133), cmd/ditto/main.go (59), staged.go (21), internal/filecopy (9). No old file grew. This counter is what perfbench's own doc comment already promised in prose: 'how many mutants a scope produces -- these are integers, identical on every machine, and a change in one is always meaningful.' It was the one number in that sentence nothing measured. It grows when code is added, and that is the point: the growth is real cost and has to be written down rather than discovered by a timeout. Moved to 734 on 2026-08-30 by backlog entries 22-26: +4 for the command line (the version subcommand, staged gating and the option builder they share) +3 for saying what a run is doing (internal/progresslaboratory and the baseline announcement), and +2 for confirming assertion kills (internal/confirminglaboratory). Each part was measured as it landed rather than one number attributed to the whole change afterwards. Then 736 to 783 (+47) for the range scope that closes backlog 21 -- changed.go, internal/staged/changed.go and the changed subcommand. That the change which SHRINKS the gate grows this counter by 47 is not a contradiction, it is the entry: this number is what the repository-sized question costs, and the gate no longer asks it on every push. Then 784, a net +1, when RunStaged and RunChanged were folded onto one runInSandbox and internal/dittotesting/gitrepository.go was added. The split between what the fold removed and what the new file added was not measured separately, so it is not claimed. Then 785 (+1) for resolving git to an absolute path rather than letting PATH decide, which SonarQube refused as a security rating on new code. Scoped exactly like ditto_mutation_test.go, so it speaks for the run it is named after. Counting costs no test command and no sandbox. docs/experiments/counting-the-real-repository.md." } } diff --git a/readme.md b/readme.md index e473874f..07131162 100644 --- a/readme.md +++ b/readme.md @@ -90,6 +90,7 @@ ditto version # which build is this? ditto run --threshold 0.8 # mutate the repository ditto staged --dry # what would a staged change cost? ditto staged --threshold 0.8 # mutate only what it justifies +ditto changed --since v1.2.0 --dry # and the same, for a change already committed ``` `ditto staged` reads the change you have staged and nothing else: which files it @@ -127,6 +128,35 @@ refuses an irregular file. Keep `-json`. It is what lets ditto tell a mutant that never compiled from one a test caught; without it, the first is counted as the second. +### In CI, where nothing is staged + +`ditto staged` reads the index, and a CI checkout has nothing in it: the change +is already committed. A gate pointed at the staged scope there skips, reports +success, and measures nothing. + +`ditto changed --since ` asks the same question of `...HEAD` — the diff +against their merge base, so a base that has moved on does not drag somebody +else's commits into the bill. + +```shell +ditto changed --since "$(git describe --tags --abbrev=0 HEAD^)" --threshold 0.8 +``` + +It refuses a checkout with uncommitted work in it, and that refusal is the point +rather than fussiness: 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. + +There is no default base. On a CI checkout the useful one 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. + +This is how **ditto's own gate** runs. The repository-sized question — 783 +mutants — died at its thirty minutes having reached about 424, four times over, +and both levers were 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%. The bill +was the wrong size rather than badly paid. + ### When the index is not the whole story A sandbox is built from the index, and that is deliberate. Some repositories do diff --git a/staged.go b/staged.go index bcca327a..c2d94b7d 100644 --- a/staged.go +++ b/staged.go @@ -114,6 +114,18 @@ func RunStaged(directory string, excludePrefixes []string, options ...Option) er return nil } + return runInSandbox(directory, plan, options) +} + +// runInSandbox is everything a scoped release does once its scope is known. +// +// Staged and changed differ in exactly one thing — which pair of trees the diff +// is read from — and shared everything below it: the same sandbox built from the +// index, the same `.ditto.json` for what git does not carry, the same notice +// when the diff could not be turned into ranges. Keeping one copy is not tidying: +// two copies of this drift, and a drift here means one entry point measuring +// different bytes than the other while both report the same kind of number. +func runInSandbox(directory string, plan StagedPlan, options []Option) error { repository, err := staged.New(staged.OSRunner{}, directory) if err != nil { return fmt.Errorf("reading the repository: %w", err) @@ -121,7 +133,7 @@ func RunStaged(directory string, excludePrefixes []string, options ...Option) er sandbox, err := repository.Materialize() if err != nil { - return fmt.Errorf("materialising the staged content: %w", err) + return fmt.Errorf("materialising the content to measure: %w", err) } defer sandbox.Close()