# Set a Change Budget Before You Refactor: A Git-Diff Gate for Messy Repos

> Source: <https://dev.to/hackrs_6393/set-a-change-budget-before-you-refactor-a-git-diff-gate-for-messy-repos-2ci2>
> Published: 2026-10-18 07:40:10+00:00

Most refactors fail in review, not in the compiler. The diff is too wide for anyone to read carefully. AI assistants make that worse: one prompt can return 900 changed lines. So put a budget on the diff before you argue about tests.

This post is one script and one table. The script measures what a branch actually touches. The table declares how much surface each refactor type may touch. You run the script on every commit. If the branch exceeds the budget, you split the branch.

A passing suite tells you behavior survived. It says nothing about review load. A 40-file diff with green tests still gets rubber-stamped at 5pm.

Three numbers predict review failure better than test count:

Track those three and "smallest safe change" stops being a vibe. It becomes a number that can fail.

`seam_budget.py`
Save this at the repo root. It needs Python 3 and git, nothing else.

``` bash
#!/usr/bin/env python3
"""seam_budget.py - refuse a refactor that touches more surface than declared."""
import json
import re
import subprocess
import sys

BASE = sys.argv[1] if len(sys.argv) > 1 else "HEAD~1"
BUDGET = {"files": 6, "lines": 180, "public_symbols": 0}
FROZEN = ("src/api/", "migrations/")
SYMBOL = re.compile(
    r"^(?:def |class |export function |export const )([A-Za-z_][A-Za-z0-9_]*)",
    re.MULTILINE,
)

def git(*args):
    run = subprocess.run(["git", *args], capture_output=True, text=True)
    if run.returncode != 0:
        sys.exit("git failed: " + " ".join(args))
    return run.stdout

def try_show(ref, path):
    run = subprocess.run(
        ["git", "show", ref + ":" + path], capture_output=True, text=True
    )
    return run.stdout if run.returncode == 0 else ""

def numstat(base):
    rows = {}
    for line in git("diff", "--numstat", base, "HEAD").splitlines():
        added, deleted, path = line.split("\t", 2)
        rows[path] = (
            0 if added == "-" else int(added),
            0 if deleted == "-" else int(deleted),
        )
    return rows

def main():
    stats = numstat(BASE)
    files = sorted(stats)
    lines = sum(added + deleted for added, deleted in stats.values())
    frozen = [path for path in files if path.startswith(FROZEN)]
    churn = 0
    for path in files:
        if path.endswith((".py", ".ts", ".js")):
            before = set(SYMBOL.findall(try_show(BASE, path)))
            after = set(SYMBOL.findall(try_show("HEAD", path)))
            churn += len(before.symmetric_difference(after))

    print(json.dumps({
        "base": BASE,
        "files": files,
        "lines": lines,
        "frozen_touched": frozen,
        "public_symbol_churn": churn,
    }, indent=2))

    failures = []
    if len(files) > BUDGET["files"]:
        failures.append("files %d > %d" % (len(files), BUDGET["files"]))
    if lines > BUDGET["lines"]:
        failures.append("lines %d > %d" % (lines, BUDGET["lines"]))
    if churn > BUDGET["public_symbols"]:
        failures.append("symbol churn %d > %d" % (churn, BUDGET["public_symbols"]))
    if frozen:
        failures.append("frozen paths touched: " + ", ".join(frozen))
    if failures:
        print("REFUSED:", file=sys.stderr)
        for item in failures:
            print("  " + item, file=sys.stderr)
        return 1
    print("ACCEPTED: surface inside budget")
    return 0

if __name__ == "__main__":
    sys.exit(main())
```

Four checks run here. File count stops sprawling branches. Line count stops reformatting mixed into logic. Symbol churn stops quiet API breaks. The frozen list stops migrations riding along with a helper rename.

`git switch -c refactor/invoice-total main`. Never start from a dirty tree.` BUDGET` and `FROZEN` and commit them alone. The gate should exist before the work does.`python3 seam_budget.py main`. Read the JSON, then the verdict.
Example output from a clean extract, trimmed for space:

```
{
  "base": "main",
  "files": ["src/billing/total.py", "tests/test_total.py"],
  "lines": 61,
  "frozen_touched": [],
  "public_symbol_churn": 0
}
ACCEPTED: surface inside budget
```

Example output from a branch that drifted:

```
REFUSED:
  files 14 > 6
  symbol churn 3 > 0
  frozen paths touched: src/api/routes.py
```

That second branch is not wrong code. It is three refactors wearing one commit.

These are starting points, not measured benchmarks. Tune them after ten runs on your own repo.

| Refactor | Files | Lines | Symbol churn | Frozen paths | 
|---|---|---|---|---|
| Rename a private helper | 2 | 60 | 0 | none | 
| Extract one module | 6 | 180 | ≤1 | none | 
| Swap a dependency | 8 | 250 | 0 | none | 
| Move a public export | 3 | 90 | ≤3 | reviewed | 
| Anything touching migrations | 1 | 40 | 0 | reviewed | 

If a row feels too tight, the repo is probably hiding a missing seam. Budget pressure is a design signal.

Two tasks here are genuinely mechanical, so a free tier is enough.

The first is drafting candidate behaviors for the seam you cannot name yet. Paste one module and ask for observable behaviors, not tests. Then write the tests yourself and check that each one can fail.

The second is running the ledger on a clean box. A remote shell with a fresh clone removes local noise: stale virtualenvs, uncommitted files, ignored build output. The number you get there is the number reviewers will see.

The operator of this account states that MonkeyCode offers free model access and a free server option, listed at 10M free tokens at the time of writing. Treat that as a claim to verify on the project page, not a permanent limit. Free allocations change.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

One rule before you paste anything: no secrets, tokens, customer rows, or internal hostnames. A refactor prompt is still a data transfer.

The script is line-based. A repo-wide formatter commit inflates line counts, so land formatting separately, then refactor.

The symbol regex misses dynamic exports, re-exports, and decorator-generated names. Extend `SYMBOL` for your language or accept blind spots.

Budgets measure surface, not risk. A one-line migration change can still be dangerous, which is why frozen paths get human review.

Never wire a free endpoint into a required CI check. Keep the ledger local or self-hosted, and keep CI green if the free box disappears.

Skip this gate if you have no baseline tests. A budget makes a refactor small, not safe. Get pinning tests in place first.

Skip it for monorepo-wide dependency upgrades. Those need staged rollout and canary deploys, not a file-count limit.

Skip it on generated or vendored code. Exclude those paths, or the churn number becomes noise.

Small diffs are a review feature, not a style preference. Set the number, fail the branch, split the work.

If you want to try the ledger without touching your laptop, the project's free server option is a reasonable sandbox for a throwaway clone.
