{"slug": "set-a-change-budget-before-you-refactor-a-git-diff-gate-for-messy-repos", "title": "Set a Change Budget Before You Refactor: A Git-Diff Gate for Messy Repos", "summary": "A developer published a git-diff gate script, seam_budget.py, that enforces a per-refactor \"change budget\" by measuring file count, line churn, public-symbol churn and touches to frozen paths, then refusing any branch that exceeds the declared limits. The approach targets review failure rather than test failure, with the author noting that a 40-file diff with green tests still gets rubber-stamped at 5pm.", "body_md": "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.\n\nThis 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.\n\nA 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.\n\nThree numbers predict review failure better than test count:\n\nTrack those three and \"smallest safe change\" stops being a vibe. It becomes a number that can fail.\n\n`seam_budget.py`\nSave this at the repo root. It needs Python 3 and git, nothing else.\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"seam_budget.py - refuse a refactor that touches more surface than declared.\"\"\"\nimport json\nimport re\nimport subprocess\nimport sys\n\nBASE = sys.argv[1] if len(sys.argv) > 1 else \"HEAD~1\"\nBUDGET = {\"files\": 6, \"lines\": 180, \"public_symbols\": 0}\nFROZEN = (\"src/api/\", \"migrations/\")\nSYMBOL = re.compile(\n    r\"^(?:def |class |export function |export const )([A-Za-z_][A-Za-z0-9_]*)\",\n    re.MULTILINE,\n)\n\ndef git(*args):\n    run = subprocess.run([\"git\", *args], capture_output=True, text=True)\n    if run.returncode != 0:\n        sys.exit(\"git failed: \" + \" \".join(args))\n    return run.stdout\n\ndef try_show(ref, path):\n    run = subprocess.run(\n        [\"git\", \"show\", ref + \":\" + path], capture_output=True, text=True\n    )\n    return run.stdout if run.returncode == 0 else \"\"\n\ndef numstat(base):\n    rows = {}\n    for line in git(\"diff\", \"--numstat\", base, \"HEAD\").splitlines():\n        added, deleted, path = line.split(\"\\t\", 2)\n        rows[path] = (\n            0 if added == \"-\" else int(added),\n            0 if deleted == \"-\" else int(deleted),\n        )\n    return rows\n\ndef main():\n    stats = numstat(BASE)\n    files = sorted(stats)\n    lines = sum(added + deleted for added, deleted in stats.values())\n    frozen = [path for path in files if path.startswith(FROZEN)]\n    churn = 0\n    for path in files:\n        if path.endswith((\".py\", \".ts\", \".js\")):\n            before = set(SYMBOL.findall(try_show(BASE, path)))\n            after = set(SYMBOL.findall(try_show(\"HEAD\", path)))\n            churn += len(before.symmetric_difference(after))\n\n    print(json.dumps({\n        \"base\": BASE,\n        \"files\": files,\n        \"lines\": lines,\n        \"frozen_touched\": frozen,\n        \"public_symbol_churn\": churn,\n    }, indent=2))\n\n    failures = []\n    if len(files) > BUDGET[\"files\"]:\n        failures.append(\"files %d > %d\" % (len(files), BUDGET[\"files\"]))\n    if lines > BUDGET[\"lines\"]:\n        failures.append(\"lines %d > %d\" % (lines, BUDGET[\"lines\"]))\n    if churn > BUDGET[\"public_symbols\"]:\n        failures.append(\"symbol churn %d > %d\" % (churn, BUDGET[\"public_symbols\"]))\n    if frozen:\n        failures.append(\"frozen paths touched: \" + \", \".join(frozen))\n    if failures:\n        print(\"REFUSED:\", file=sys.stderr)\n        for item in failures:\n            print(\"  \" + item, file=sys.stderr)\n        return 1\n    print(\"ACCEPTED: surface inside budget\")\n    return 0\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```\n\nFour 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.\n\n`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.\nExample output from a clean extract, trimmed for space:\n\n```\n{\n  \"base\": \"main\",\n  \"files\": [\"src/billing/total.py\", \"tests/test_total.py\"],\n  \"lines\": 61,\n  \"frozen_touched\": [],\n  \"public_symbol_churn\": 0\n}\nACCEPTED: surface inside budget\n```\n\nExample output from a branch that drifted:\n\n```\nREFUSED:\n  files 14 > 6\n  symbol churn 3 > 0\n  frozen paths touched: src/api/routes.py\n```\n\nThat second branch is not wrong code. It is three refactors wearing one commit.\n\nThese are starting points, not measured benchmarks. Tune them after ten runs on your own repo.\n\n| Refactor | Files | Lines | Symbol churn | Frozen paths | \n|---|---|---|---|---|\n| Rename a private helper | 2 | 60 | 0 | none | \n| Extract one module | 6 | 180 | ≤1 | none | \n| Swap a dependency | 8 | 250 | 0 | none | \n| Move a public export | 3 | 90 | ≤3 | reviewed | \n| Anything touching migrations | 1 | 40 | 0 | reviewed | \n\nIf a row feels too tight, the repo is probably hiding a missing seam. Budget pressure is a design signal.\n\nTwo tasks here are genuinely mechanical, so a free tier is enough.\n\nThe 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.\n\nThe 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.\n\nThe 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.\n\nDisclosure: This article was prepared as part of MonkeyCode's product outreach.\n\nOne rule before you paste anything: no secrets, tokens, customer rows, or internal hostnames. A refactor prompt is still a data transfer.\n\nThe script is line-based. A repo-wide formatter commit inflates line counts, so land formatting separately, then refactor.\n\nThe symbol regex misses dynamic exports, re-exports, and decorator-generated names. Extend `SYMBOL` for your language or accept blind spots.\n\nBudgets measure surface, not risk. A one-line migration change can still be dangerous, which is why frozen paths get human review.\n\nNever 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.\n\nSkip this gate if you have no baseline tests. A budget makes a refactor small, not safe. Get pinning tests in place first.\n\nSkip it for monorepo-wide dependency upgrades. Those need staged rollout and canary deploys, not a file-count limit.\n\nSkip it on generated or vendored code. Exclude those paths, or the churn number becomes noise.\n\nSmall diffs are a review feature, not a style preference. Set the number, fail the branch, split the work.\n\nIf you want to try the ledger without touching your laptop, the project's free server option is a reasonable sandbox for a throwaway clone.", "url": "https://wpnews.pro/news/set-a-change-budget-before-you-refactor-a-git-diff-gate-for-messy-repos", "canonical_source": "https://dev.to/hackrs_6393/set-a-change-budget-before-you-refactor-a-git-diff-gate-for-messy-repos-2ci2", "published_at": "2026-10-18 07:40:10+00:00", "updated_at": "2026-10-18 07:44:30.854686+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["git", "Python"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/set-a-change-budget-before-you-refactor-a-git-diff-gate-for-messy-repos", "markdown": "https://wpnews.pro/news/set-a-change-budget-before-you-refactor-a-git-diff-gate-for-messy-repos.md", "text": "https://wpnews.pro/news/set-a-change-budget-before-you-refactor-a-git-diff-gate-for-messy-repos.txt", "jsonld": "https://wpnews.pro/news/set-a-change-budget-before-you-refactor-a-git-diff-gate-for-messy-repos.jsonld"}}