DEV Community

UB3DQY
UB3DQY

Posted on

Six small PRs later, repo hygiene stopped being a suggestion

Over the last 24 hours I did one of those jobs that sounds smaller than it really is.

On paper it was boring: tighten up repo hygiene in a small Python project.

In practice it meant taking a repo where the right tools technically existed, but mostly as polite background noise, and turning them into something that actually pushes back.

That distinction matters more than people admit.

A lot of repos have hygiene in the aspirational sense. There is a formatter. There is a linter. There is a workflow file. There is maybe even a note somewhere saying “we should probably enforce this.” And yet the real contract of the repository is still social. If somebody forgets to sort imports, or reformats one file strangely, or adds a new script with a different style, nothing happens except maybe a vague feeling that the code is getting fuzzier around the edges.

That was roughly where this repo was.

By the end of the day, it wasn’t.

What changed

The repo is a slightly odd little Python codebase: scripts, hooks, some WSL/Windows friction, and enough operational glue that a careless formatting pass can do real damage.

The hygiene sequence ended up landing as six small PRs:

  1. add a proper dev dependency group
  2. enable Ruff import sorting only, with an explicit Python target version
  3. add a narrow Ruff import-sorting check to CI
  4. run a style-only ruff format pass
  5. add .git-blame-ignore-revs for that style commit
  6. add ruff format --check to CI

That list is tidy now. It did not start tidy.

The first temptation was the obvious one: if Ruff is underused, just turn on more Ruff. Add UP, add B, wire in pre-commit, maybe clean up packaging while we’re here, maybe finally fix the command entry point. The classic “while I’m touching hygiene, I might as well modernize the whole repo” impulse.

That would have been a mistake.

The actual winning move was to narrow the scope until every step was defensible on its own.

The difference between “installed” and “real”

The repo already had Ruff.

Which is exactly why this kind of work gets postponed forever.

When a tool already exists in pyproject.toml, people start speaking about it in the present tense. “We use Ruff.” “We have formatting.” “The repo is linted.” But if nothing in the actual merge path forces those claims to matter, what you really have is a tool-shaped decoration.

That was the first useful correction from this round of work:

a tool is not part of the contract until CI can turn red because of it

So the order mattered.

Not pre-commit first. Not “let’s all remember to run it locally.” Not “there’s a command for that.”

CI first.

Once ruff check --select I scripts/ hooks/ and ruff format --check scripts/ hooks/ sit in the workflow, the repo changes shape. Future PRs can’t quietly reintroduce unsorted imports or drifting formatting and call it an accident. The repository starts enforcing a boundary instead of merely describing one.

That is a much more important transition than it sounds.

Why we did not turn on all the rules

This was probably the most useful design decision of the whole sequence.

We only enabled Ruff’s I rule at first. Import sorting. Nothing broader.

That is not because the repo is perfect otherwise. It isn’t. There is still baseline lint debt sitting there, and we know it. But broadening the ruleset too early would have mixed three different jobs together:

  • introduce a new contract
  • pay down old debt
  • argue about the meaning of every new warning

That is how hygiene work turns into a swamp.

So the repo took the adult route instead:

  • start with the rule that is almost entirely mechanical
  • fix only what that rule surfaces
  • make it enforced
  • defer the noisier categories until there is a reason to take them on

That sounds conservative. It is. It is also how the change actually got merged.

There was one wrinkle, and it was a real one.

Three hook files use an intentional sys.path.insert(...)-then-import pattern. It is ugly, but it is tied to the current package layout and runtime boundary. Ruff’s import sorting model does not like that pattern, and when we tried to push it through blindly, it started producing destructive splits.

So instead of pretending the linter is always right, we added narrow per-file-ignores for those three files and moved on.

That was the correct tradeoff.

Linters are tools. They are not clergy.

The most boring PR was secretly important

The style-only formatting pass was the part that looks most cosmetic from the outside.

Run ruff format scripts/ hooks/.
Reformat a couple dozen files.
Move on.

But that step has two hidden traps.

First, you need to prove it is actually style-only. In this repo that meant checking the non-import baseline before and after, making sure the same existing errors were still the same existing errors, and not quietly smuggling in behavior changes under the name of formatting.

Second, once you do a broad formatting commit, git blame gets uglier unless you clean up after yourself.

So the format pass was immediately followed by .git-blame-ignore-revs.

I’m mentioning that because a lot of teams skip it, and then six months later every touched line looks like it was “written” by the style commit. It is a small operational courtesy that saves a lot of irritation later.

Formatting the repo is easy.
Formatting the repo without damaging the usefulness of its history is slightly less easy.

Still worth doing.

The part that kept this from becoming a mess

This repo has a weird workflow on purpose: plans, reports, explicit verification, narrow whitelists, and a very annoying habit of stopping when the written plan no longer matches reality.

That sounds bureaucratic until you hit the first real discrepancy.

And there were several.

One plan version assumed uv.lock was tracked. It wasn’t.

Another assumed project.optional-dependencies was the right place for dev tooling. The docs said otherwise: for this setup, dependency-groups was the semantically correct path.

One acceptance check assumed a clean diff on files that were already dirty before the task started.

Another workflow file had line-ending churn that made the raw diff look noisier than the actual content change.

None of those were dramatic bugs. They were the more ordinary kind of engineering problem: stale assumptions surviving just long enough to confuse the next step.

The only thing that reliably prevented those from turning into sloppy implementation was a simple rule:

when the docs, the code, and the plan disagree, the plan loses

That sounds obvious. It still needs to be enforced.

Where the repo ended up

At the end of the sequence, the workflow was doing real work.

The main CI path now enforces:

  • index freshness
  • structural wiki lint
  • Ruff import sorting for scripts/ and hooks/
  • Ruff formatting checks for scripts/ and hooks/
  • Python AST syntax validity

That is not some grand platform rewrite. It is a modest hardening pass on a small codebase.

But that is exactly why I like it.

Too much engineering writing treats hygiene as if it only becomes interesting once there is a huge monorepo, a staff-sized platform team, or a catastrophe. Most repos do not live there. Most repos live in the much less glamorous zone where a dozen small inconsistencies slowly teach everybody that the rules are optional.

This was the opposite kind of day.

No reinvention. No giant “quality initiative.” No holiness around tool choice. Just six small PRs in the right order, each one making the next one easier to justify.

That is usually what “maturity” looks like in a repo, if you strip away the self-importance.

What I would do again

If I had to repeat the same cleanup tomorrow in another Python repo, I’d keep the same order:

  1. declare dev tooling properly
  2. enable one narrow lint rule with a high signal-to-drama ratio
  3. enforce it in CI
  4. do the style-only format pass
  5. add blame-ignore for that commit
  6. only then expand the gate

And I would still resist the urge to “just enable everything.”

Not because broad linting is bad. Because sequencing matters.

A repo does not become cleaner when you dump more rules into it. It becomes cleaner when the rules it has are real, narrow enough to survive contact with reality, and enforced in the place that actually decides what lands.

That was the work of the last 24 hours.

Not glamorous.
But now the repo pushes back.

That’s better.

Top comments (0)