The Claude starburst logo in terracotta and the Claude wordmark in black on a white card

A Claude Code review skill for a repo I don't own — self-review, PR body, and diff hunks

Porting a Claude Code release skill to a repo I cannot merge or tag: what gets cut, why git diff HEAD lets review run before the commit, where ignore rules go.

On this page

Introduction

I have a /release skill on this blog that runs an entire release without me touching the keyboard: it opens a GitHub issue, cuts a branch, validates, reviews the diff, opens a pull request, squash merges it, tags main, and publishes the release notes. Last week I went to reach for the same thing on a work monorepo and stopped at the first step. That repository belongs to the organisation, not to me. I do not merge my own work there, I do not cut tags, and the release cadence is somebody else’s call.

The useful finding was that what survives the cut is not a smaller release skill. It is a different artifact, because the thing it produces stops being a merge and becomes a handoff document that a colleague reads before approving anything. This article walks through which steps came out, what replaced them, and the three design decisions that only became visible once ownership was gone.

The release skill assumes it owns the repo

My blog’s release skill runs seventeen numbered steps across four phases. Written out, most of them turn out to be assertions of ownership rather than engineering:

Step What it needs
Create a GitHub issue, assign a milestone Issue write access, and a milestone convention that is mine
Bump the version in package.json Authority over the version number
Promote [Unreleased] in CHANGELOG.md Authority over the changelog format
Squash merge the pull request Merge rights on the default branch
Tag main and push the tag Write access to refs
Create the GitHub release Release authority
Close the issue with a released-in comment Issue write access again

Seven of the seventeen steps are gone before any code is considered. The remaining ten are the ones that were doing real work: read the current state, validate, review, branch, commit, push, and report.

The tag ordering rule that the release skill spends a whole section defending is a good illustration. It exists because squash merge replays a diff as a new commit on main and discards the branch, so a tag created before the merge points at a commit that is not an ancestor of main and git describe can never see it again. That is a genuinely subtle trap, and it is completely irrelevant to a repository where I never tag anything. Most of the release skill’s hard-won knowledge is like this: correct, expensive to learn, and scoped to a repository I control.

Which release steps survive on a repo I do not ownTwo columns compare the same flow, each grouping its steps into rows. The left column is a release skill on a repository I own: seventeen steps drawn as nine rows, of which four are struck through as needing write access that is not mine. Those four are creating an issue and milestone, bumping the version and changelog, squash merging the pull request, and tagging and releasing and closing the issue. The right column is the review skill: the surviving steps drawn as five rows, plus one row that is new, writing the pull request body and the diff explanation. The lower half of the right column is empty, which is the subtraction drawn.Release skill · a repo I ownRead the stateCreate issue, assign milestoneVersion bump, CHANGELOGCode reviewValidateBranch and commitPushSquash merge the PRTag, release, close issueReview skill · the team’s repoRead the stateSelf-reviewSync checkValidateBranch, commit, push+Write PR body, diff explanation✓ kept · ✕ needs write access that is not mine · + new
The struck rows are the argument. The left column groups the seventeen release steps into nine rows; the four struck ones need write access that is not mine, and the empty lower half of the right column is what removing them leaves.

Cutting the steps that need somebody else’s write access

What is left runs in six phases: read the state, self-review the diff, check whether the remote moved, validate, then branch and commit and push, then write the review documents. The last phase is the one that did not exist in the release skill at all.

On my own repository the output of a successful run is a tag and a published release. On a team repository the output is two markdown documents: a pull request body and a hunk-by-hunk explanation of the diff. I gave the agent two real examples from earlier pull requests and told it to treat them as the format specification rather than inventing a template, because those documents already had a shape that survived review. Five sections, in reading order: the problem with its root cause traced, a verification table of what actually ran, the work organised by change rather than by file, what the change costs, and notes.

The skill stops after git push. It composes the compare URL from git remote get-url origin and hands it over, and it never calls gh. I raise the pull request myself, which is not a limitation I worked around but the correct behaviour for a repository where opening a pull request is a social act as much as a technical one.

“How can you review without commit?”

When the agent offered to run the self-review before the commit, I pushed back. It seemed like a category error: review reads a diff, a diff is what a commit produces, so reviewing before committing sounded like reviewing nothing.

The answer is that git diff HEAD covers staged and unstaged changes together, against the last commit. There is nothing to wait for. Which command to read depends only on what is in flight:

Three working-tree states, three diffsThree rows pair a working-tree state with the git command that reads it. Uncommitted work only takes git diff HEAD; commits already on the branch take git diff main...HEAD; when both are present, both commands are read together as one change. The first row has no commit in it at all, which is why a review can run before one is made.What is in flightThe diff to readUncommitted work onlygit diff HEADCommits on the branch onlygit diff main...HEADBothgit diff HEADgit diff main...HEADread together as one change
Which command to read depends only on what is in flight. The first row has no commit in it at all, which is why the review can run ahead of one.

That matters more than it first appears. If the review runs after the commit, every finding it produces has to land as a second commit, and the branch grows an “address review feedback” commit that a human reviewer then has to read and mentally discard. Running the review first means the fixes are indistinguishable from the original work, because they are part of it. The reviewer reads one coherent change instead of a change plus its errata.

I was wrong about the mechanism and the objection was still worth making, because it forced the ordering to be justified rather than assumed.

check:fix writes to the tree, so it runs before the commit

The monorepo uses Biome, and the script the repository standardises on is check:fix, which is biome check --write. It does not report problems. It rewrites files.

That single fact fixes the phase order. Validation cannot sit after the commit, because it would leave formatting changes stranded in the working tree that then need a second commit to collect. It cannot be swapped for the read-only check either, since the repository’s own convention is that formatting is applied rather than complained about. So the order is forced: review, apply fixes, validate, commit, push, write the documents.

Six phases, with validation ahead of the commitSix phases run top to bottom: read the state, self-review the diff, sync check, validate, branch and commit and push, then write the review documents. The validate phase is marked as writing to the working tree, because check:fix is biome check --write and its edits must land in the same commit. The final phase is marked as new, having no counterpart in the release skill.1Read the state2Self-review the diff3Sync check4Validate5Branch, commit, push6Write the review documentsWrites to the working treecheck:fix is biome check --write.Its edits have to land in thesame commit, so it runs first.NewThe release skill had noequivalent of this phase.
Phase 4 is the constraint. Because it writes rather than reports, it cannot sit after phase 5, and the rest of the order follows from that.

The agent proposed detecting which workspaces a change touched rather than running the full suite every time, and I took it. The mapping is mechanical, from changed path to package filter, with one exception it argued for and which I think is right: a change under the shared common package escalates to validating everything. The three applications each consume that package’s built output, so a modified type compiles cleanly inside common and breaks at the consumer’s tsc. Narrow detection would report green on a change that does not build.

One detail worth stating because it looks alarming the first time: the client and admin builds emit into directories that are already ignored, and the server build does too. A full validation run leaves git status clean. Build output showing up there would mean something was wrong with a path, not that the build succeeded loudly.

The ignore rules go in .git/info/exclude, not .gitignore

The two generated documents have to live somewhere, and the obvious answer is a scratch directory in the repository with an entry in .gitignore. That answer is wrong for the same reason the release steps were wrong.

.gitignore is a tracked file. Adding a personal scratch directory to it means a diff in my pull request that reviewers have to read, a line committed to a repository I do not own, and my local convention imposed on everyone who clones it. None of that is what I wanted. I wanted a place to put a draft.

.git/info/exclude has the same syntax and the same effect, and it is per clone and untracked:

Terminal window
echo '.ignore/' >> .git/info/exclude

Verifying it works the same way as any other ignore rule, and git check-ignore will tell you which file supplied the rule:

$ git check-ignore -v .ignore/pr-body.md
.git/info/exclude:8:.ignore/ .ignore/pr-body.md

The same file ended up holding the skill as well. .claude/ is not a personal directory in this repository — it already tracks shared agents and slash commands, so a new folder under it reads as something the team is meant to pick up. A workflow skill encoding one person’s preferences is not that, at least not before anyone has asked for it:

$ tail -3 .git/info/exclude
scripts/
.ignore/
.claude/skills/review

Three entries, and not one of them a line anybody else has to review.

The trade is that the rule does not travel. Nobody else’s clone has it, and neither will mine after a fresh clone. For a scratch directory holding drafts that are meant to be pasted into a web form and then forgotten, that is the correct trade, and it is the same judgement as not tagging: when the repository is not mine, the default is to leave no trace in it.

A checklist that names the codebase finds what a generic one does not

A review agent given “look for bugs” produces a list of things that are not bugs. The agent derived the checklist from the repository itself, and the dimensions that earn their place are the ones that could not have been guessed from outside.

The block that leaves no trace

The strongest example is a content-block reducer in the server. It walks a list of blocks and dispatches each one through a callback map in which every handler except the text handler is optional, with each branch guarded as “this block type, and a callback exists for it”.

An absent callback drops a block without raising anythingFour block types are listed against what a provider defines for each and what happens to them. Text is required and reaches the model; image and reasoning are defined and reach the model; the tool handler is not defined, and that block is dropped with no error. The guard beneath reads block type equals tool and callback map has toolUse, which is falsy when the callback is absent, and there is no else branch to catch it.Block in the messageWhat the provider definesOutcometextrequiredreaches the modelimagedefinedreaches the modelreasoningdefinedreaches the modeltoolnot defineddropped, no errorblk.type === 'tool' && cbMap.toolUseNo callback makes the guard falsy, and there is no else branch.
There is no else branch. An absent callback makes the guard falsy, so the block is skipped by the same code path that skips nothing at all.

A provider integration that never defined a handler for some block type therefore drops that block silently. Nothing throws. Nothing logs. The content simply does not reach the model, and the failure surfaces later as a malformed conversation or an API validation error with no obvious cause.

That exact shape has already produced shipped bugs twice in this codebase, so a new block type arriving without a corresponding handler in every provider is worth a high-severity finding. No general-purpose reviewer would ever look for it, because there is nothing in the code that looks like a mistake.

Which file registers a route decides who can reach it

The backend registers routes under prefixes, and only two of them carry authentication middleware. A handler that reaches object storage or the database is safe under those two prefixes and publicly reachable anywhere else, which makes “which file registers this route” a security question rather than an organisational one.

The repository’s own routing entry point already carries a comment saying exactly that. Encoding an existing comment as a review dimension costs nothing and catches the one mistake that would actually matter.

What the checklist refuses to report

Just as useful is the list of findings the checklist rules out, because each one is already guaranteed by something downstream:

Not reported Already handled by
Formatting, import order, quote style check:fix, which rewrites them two phases later
Unused imports, let that should be const Biome’s lint rules, same phase
Type errors the tsc step inside every workspace build

A review that reports these is spending the reader’s attention on findings that were never going to reach the pull request, and burying the ones that need human judgement underneath them.

One language setting turned out to be two

I asked for all the generated content in the language my colleagues read. The agent checked the repository’s history before agreeing and came back with a conflict: every commit in the log is English, in Conventional Commits form, including everyone else’s. Commit messages in another language would have been the only ones of their kind.

So the split is by audience rather than by preference. The review documents follow the reviewer, because a person reads them once and decides something. The commit messages stay English, because they join a shared log that outlives the review and belongs to everyone. I had been treating “language” as a single setting when it is two, and the agent noticed because it read the log rather than my instruction.

This is the part of the exercise I would not have got to alone, and it is worth being precise about who did what: the scope, the format specification, and every final call were mine, and the conflict I did not see was the agent’s to find.

The template that could not contain its own examples

One failure is worth recording because the fix is not the obvious one.

The two document templates contain worked examples, and those examples are themselves fenced code blocks. A ```markdown block containing a ```diff block terminates at the inner fence. The agent’s first attempt was to prefix each inner fence with a zero-width space, which renders invisibly and stops the parser from matching. It works, and it leaves invisible characters scattered through a file that other people will read.

Removing them again did not go smoothly:

Terminal window
perl -i -pe 's/\x{200b}//g' templates/diff-explanation.md

That command exits zero and changes nothing. Without -CSD, Perl reads the file as bytes rather than as UTF-8, so \x{200b} never matches the three-byte sequence actually in the file. The tell was that grep -c still counted four occurrences after a substitution that had reported no error at all:

remaining: 4

Two fixes, both small. perl -CSD makes the substitution see characters instead of bytes. And the outer fence became ~~~, which markdown accepts as an alternative fence marker, so the inner backtick fences need no escaping and the file contains no invisible characters at all. The second fix means the first was never needed, which is the usual shape of this kind of problem.

Summary

Porting a release skill to a repository I do not own turned out to be a subtraction exercise with one addition. Seven of seventeen steps came out because they asserted ownership: issues, milestones, version numbers, changelog authority, merges, tags, releases. What replaced them is a document-writing phase, because a colleague reading a pull request body is now the last step instead of a green check on a merge.

Three decisions only became visible once ownership was gone. Review runs before the commit, because git diff HEAD makes it possible and folding fixes into the original commit gives the reviewer one change to read. Validation runs before the commit too, because the repository’s formatter writes rather than reports. And the scratch directory is ignored through .git/info/exclude rather than .gitignore, because a tracked ignore file is one more thing I would be changing in somebody else’s repository.

The general form, if there is one: a workflow skill encodes a set of permissions as much as a set of steps, and the steps that survive a move are the ones that were never about permission in the first place.

References

Share this article