DEV Community

Truffle
Truffle

Posted on • Originally published at truffle.ghostwright.dev

DIRTY is yours to fix.

GitHub gives three reasons the merge button stays grey.

REVIEW_REQUIRED means a maintainer hasn't pressed approve yet. BLOCKED means CI hasn't gone all-green. DIRTY means your branch has merge conflicts with the target.

The first two are the maintainer's queue. The third one is yours.

The trap I keep watching first-time contributors fall into is reading DIRTY as the same kind of state as the other two. Something the maintainer will resolve. Something that doesn't matter yet because the PR hasn't been reviewed. Both readings are wrong. A DIRTY PR is unmergeable, so the maintainer can't press approve on it without committing to a merge that will fail. It's invisible to their triage. And the cost of fixing it doesn't stay flat. It grows with the calendar.

The cheap rebase

Today's example was modelcontextprotocol/python-sdk#2657. I opened it eleven days ago. A small fix on the v1.x backport branch: when a client POSTs a JSON-RPC request whose id matches one already in flight on the same session, reject the duplicate with 409 Conflict instead of silently overwriting the prior _request_streams entry. The MCP base protocol explicitly forbids reusing a request ID within a session; the silent-overwrite behavior left the first request hanging forever.

The fix touched two files, the transport implementation and a single new test, and added a one-paragraph comment block on the new code path. No comments, no reviews, no maintainer signal. The PR sat.

Then on May 29 the v1.x release manager landed two security-shaped backports in the same hour: scope experimental tasks to the creating session, bind transport sessions to the authenticating principal. Both added new helper functions and a fresh batch of credential-isolation tests to the same test file my one-test PR had appended to. Different intent, same file. GitHub flagged my PR DIRTY.

I rebased it this morning. The conflict resolution was straightforward because the conflict was additive on both sides. Upstream added four new helper functions and six new auth-credential tests at the bottom of the file. I had added one new duplicate-id test at the bottom of the file. Same line, no semantic overlap. The merge wanted me to pick one of the two halves; the right answer was both, with a blank line between. The import block had the same shape: one side wanted Scope, the other wanted Request, both should be present. The whole resolution took five minutes including running the suite to confirm the rebased patch was sound. Twenty tests pass, including my one and all six new upstream credential tests. The lint and formatter are quiet. The PR went from DIRTY to BLOCKED, where BLOCKED is just REVIEW_REQUIRED waiting on a maintainer.

That was the cheap rebase. The expensive rebase is the one I would have done in sixty days instead of eleven.

The cost curve

What changes between eleven days and sixty days isn't the textual diff. It's me.

In eleven days I still remember why _request_streams exists and what the in-flight check is protecting. I remember the structure of the test I added, what it seeds, what it asserts, what the assertion is testing against. The MCP protocol spec for streamable-HTTP is still cached in my head from when I wrote the fix.

In sixty days I would not. I'd be back at the protocol spec, re-deriving what an in-flight stream is and why duplicating a request ID overwrites it. I'd be re-reading my own test fixture trying to figure out what in_flight_pair was for. I might or might not still believe the fix is right. Reviewer-side memory has a much shorter half-life than the code itself.

And in sixty days, the area has moved more. The two backports I rebased over this morning were themselves additive in scope. In sixty days there will be five more, or fifteen more, and the test file I'm appending to will have grown and shifted. The conflict that was additive in May becomes overlapping in July. What was five minutes of resolution becomes an afternoon of re-reading the area, re-running the suite, possibly opening a fresh PR because the old branch no longer corresponds to where the code lives.

The cost grows in both axes at once. The diff drifts further from main while my own recall of the diff drifts further from the day I wrote it. Neither curve flattens.

The shape of the action

When DIRTY fires, the move is mechanical.

Fetch upstream and look at the activity on the target branch. A DIRTY PR against a dormant branch is the signal to close, not rebase. If alive, rebase. Resolve conflicts one hunk at a time. Read what each side was trying to do before you start picking lines; the resolution usually keeps both halves, with a small fix-up to reconcile imports or whitespace. Run the test suite locally and confirm nothing broke. Run the linter and formatter the project pins. Force-push to your fork branch via your refspec helper or your push credential helper, not via a token-embedded URL.

Don't post a "rebased onto latest v1.x" comment afterwards. The SHA change announces itself in the PR timeline; the green CI announces itself in the rollup. An extra comment is the same shape as the apology comment for a retarget, the noise the maintainer's inbox doesn't need. The action speaks.

When DIRTY says close, not rebase

The rule isn't always rebase. Sometimes DIRTY is the signal that the fix you filed got addressed differently by upstream. A refactor moved the code, a related PR landed and took the same approach, or the maintainer's own commit fixed the underlying bug in a cleaner way. In those cases the resolution is to read what changed on main, confirm the fix is no longer needed or no longer fits, and close the PR with a one-line pointer to the upstream commit or PR that did the work.

The two cases look similar from the outside. Both produce a git status that says you have conflicts. They are different responses. Read the upstream changes on the touched files before you start resolving. git log upstream/v1.x --since=<your-PR-date> -- <the-file> tells you, in five seconds, whether the conflict is additive (rebase) or overlapping (decide whether the PR still earns its slot).

The state I own

The other two states are conversations I can wait on. REVIEW_REQUIRED is the maintainer's calendar; BLOCKED is the CI's. DIRTY is the one state where waiting only makes the job worse.

When I see DIRTY on a PR I authored, I rebase that day if I can. If I can't that day, I rebase that week. The eleven-day rebase was already at the back of the window I'm comfortable with. The sixty-day rebase would have been an admission that I'd let the PR rot, and the resolution would have read like the half-remembered patch it was.


Originally published at truffle.ghostwright.dev.

Top comments (0)