What it is.
couplingguardis a free GitHub Action and GitLab CI integration that detects file coupling risk in pull requests by analyzing your repository's git co-change history. On every PR it posts a collapsible markdown comment with normalized coupling scores for the files you're changing, suggests reviewers fromCODEOWNERS, and can optionally fail CI above a configurable risk threshold. Install in 5 lines of YAML. No signup, no hosted service, MIT licensed.
The hidden cost of code review: two files that always break together still ship in the same PR with no one looking at both sides. Your git log has known about this pairing for months β every co-change is a data point. Nobody reads it. couplingguard does, on every PR, before merge.
The leverage point is PR open time, not the post-incident review. AI coding agents (Copilot, Claude Code, Cursor) now routinely land diffs touching 15β30 files at once; coupling risk has never been higher or harder to spot by scrolling a unified diff. This is the cheapest bug-prevention tool you can add to your stack: five lines of YAML, an MIT license, and a comment on every PR.
π¬ The demo above is a real rendered video (source MP4, 24 s, 1080p, 2.1 MB). Built with Remotion β the source composition lives at
demo/remotion/. Runnpm install && npm run buildin that folder to re-render it yourself (npm run build:gifproduces the inline-embeddable version above). An accessible static SVG fallback is atassets/animated-demo.svg.
| If you are⦠| What couplingguard gives you |
|---|---|
| A platform engineer at a monorepo company | A quantified, CI-enforceable coupling budget that replaces tribal knowledge about "files that always break together" |
| A senior reviewer on AI-generated PRs | A second pair of eyes that flags coupled files the diff doesn't obviously show β before you approve a 25-file Copilot change |
| An OSS maintainer reviewing external contributions | Instant context on which historical owners should weigh in, on top of static CODEOWNERS |
| A DevOps lead enforcing review standards | An opt-in fail_threshold that exits 1 when a PR's coupling density crosses a line you choose |
| A solo developer on a long-running project | A check on your own blind spots: which files in your codebase you've forgotten are coupled |
name: Coupling Guard
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
coupling:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required: couplingguard needs the full git log
- uses: Meru143/couplingguard@v1
with:
github_token: ${{ github.token }}Real output from running couplingguard against its own repository
(synthetic PR over 10 commits of real history, captured from
tests/e2e/test_dogfood.py):
π couplingguard β 6 pairs detected, highest risk: π΄ 1.00
File in PR Coupled With Score Risk Co-changes pyproject.tomlskills-lock.json1.00 π΄ High 2/2 commits tests/integration/test_github_poster.pytests/integration/test_gitlab_poster.py1.00 π΄ High 2/2 commits .gitignorepyproject.toml0.67 π‘ Medium 2/3 commits .gitignoreskills-lock.json0.67 π‘ Medium 2/3 commits
Note the paired integration test files at 1.00 β test_github_poster.py and
test_gitlab_poster.py always land in the same commit because they cover
mirror-image functionality. A reviewer looking only at the GitHub test
file would benefit from knowing the GitLab one almost certainly changed
too.
Illustrative example showing the score-delta line on re-push and
CODEOWNERS-based reviewer suggestions (the names are placeholders β the
real action only suggests usernames that actually appear in your
CODEOWNERS file):
π couplingguard β 2 pairs detected, highest risk: π΄ 0.82
β οΈ Score changed since last push: π‘ 0.45 β π΄ 0.82 β
File in PR Coupled With Score Risk Co-changes src/payment.pysrc/billing.py0.82 π΄ High 41/50 commits src/payment.pytests/test_billing.py0.64 π‘ Medium 32/50 commits Suggested reviewers for coupled files: @alice, @team-payments
The comment is collapsible (<details>-wrapped) and edits itself on
every push to the PR with a "score changed" line showing the delta.
| Input | Type | Default | Description |
|---|---|---|---|
github_token |
string | ${{ github.token }} |
Token for PR comment + check |
gitlab_token |
string | "" |
Personal access token for GitLab CI |
lookback_days |
number | 90 |
Days of history to analyze |
min_occurrences |
number | 3 |
Minimum co-change count to include a pair |
max_pairs |
number | 10 |
Maximum pairs shown in the comment |
low_threshold |
number | 0.3 |
Score boundary π’ β π‘ |
high_threshold |
number | 0.7 |
Score boundary π‘ β π΄ |
fail_threshold |
string | "" |
low/medium/high to fail CI; empty disables |
exclude |
string | "" |
Newline-separated glob patterns |
publish_dashboard |
boolean | false |
Generate static dashboard + history + badge artifact |
dry_run |
boolean | false |
Print comment to stdout; don't post |
flowchart TD
A[git log<br/>lookback_days, no-merges] --> B[co-change matrix<br/>file pairs Γ commit count]
B --> C[normalize<br/>score = co_count / max(count_a, count_b)]
C --> D{filter by<br/>min_occurrences}
D --> E[PR analyzer<br/>keep pairs touching PR files]
E --> F[classify risk<br/>π’ < 0.3 β€ π‘ < 0.7 β€ π΄]
F --> G[CODEOWNERS lookup<br/>suggest reviewers]
G --> H[render markdown<br/>+ hidden JSON marker]
H --> I[find existing<br/>PR comment by marker]
I -->|exists| J[edit in place<br/>with delta line]
I -->|new| K[create issue comment]
J --> L[fail_threshold check<br/>exit 0 / 1]
K --> L
L --> M{publish_dashboard?}
M -->|yes| N[append history JSON<br/>+ Chart.js HTML<br/>+ shields.io badge]
M -->|no| O[done]
N --> O
style A fill:#fef3c7,stroke:#f59e0b,color:#000
style C fill:#dbeafe,stroke:#3b82f6,color:#000
style F fill:#fce7f3,stroke:#ec4899,color:#000
style L fill:#dcfce7,stroke:#16a34a,color:#000
The key insight is normalization: raw co-change counts inflate for
old / large files, while co_count / max(count_a, count_b) produces a
0β1 ratio that's comparable across repos of any size and age.
pip install couplingguard
couplingguard --repo . --dry-run --lookback-days 90The CLI uses the same code path as the Action β --dry-run prints
the rendered PR comment to stdout without reaching GitHub, so you
can preview what couplingguard would post against any local repo.
Run couplingguard --help for the full flag list (every Action input
has a matching CLI flag).
coupling:
image: python:3.11
variables:
GIT_DEPTH: "0" # required: GitLab clones shallow by default
GITLAB_TOKEN: ${GITLAB_TOKEN}
script:
- pip install couplingguard
- couplingguard --repo .
only:
- merge_requestsCI_SERVER_URL, CI_PROJECT_ID, and CI_MERGE_REQUEST_IID are
auto-set by every GitLab Runner. GITLAB_TOKEN should be a
project access token
with the api scope, stored as a masked CI/CD variable.
For GitHub Actions, couplingguard needs:
contents: readto read the git history.pull-requests: writeto post / edit the comment.
For GitLab CI, the GITLAB_TOKEN needs api scope on the project.
When publish_dashboard: true, the action writes coupling-history.json,
coupling-dashboard.html, and coupling-score.json to the workspace and
uploads them as a GitHub Actions artifact. Nothing is committed back to
your repo unless you add an explicit git commit && git push step yourself.
Yes β entirely. MIT licensed, no paid tier, no signup, no hosted service. The Action runs on your own runner; your code never leaves your CI.
File coupling is when two files in your repository historically change together. Tightly coupled files almost always need to be modified in the same PR, but reviewers can't see the relationship from the diff alone. Coupling is one of the strongest predictors of regression risk: changing one half of a coupled pair without the other is how production incidents start. Adam Tornhill's Your Code as a Crime Scene covers the research; couplingguard operationalizes it at PR time.
A pair where a.py was touched 100 times, b.py 5 times, and both together 5 times is not the same as a pair where both were touched 5 times each. Raw count = 5 in both cases. Normalized:
5 / max(100, 5) = 0.05β noise (file_a changes for many reasons)5 / max(5, 5) = 1.00β genuine coupling (whenever one changes, so does the other)
The formula is score = co_changes / max(file_a_total_changes, file_b_total_changes). It produces a 0β1 ratio comparable across repos of any size and age.
π See the coupling cheatsheet for the full math, default thresholds, common couplings to look for, and per-repo-type tuning (solo, small team, monorepo, mature OSS library).
Default actions/checkout@v4 does a shallow clone (depth=1). couplingguard needs the full git log to count co-changes across the configurable lookback_days window. If you forget, the Action exits 1 with an actionable error (E001) rather than producing wrong results from a truncated history.
Yes. For repos with 50+ committers and 10k+ commits in the window:
- Use
excludeto drop noisy paths (docs, migrations, generated code, lockfiles). - Bump
min_occurrencesto 5+ to filter out rare pairs. - Lower
lookback_daysto 60 β recent coupling is more actionable than ancient.
The matrix builder is O(commits Γ avg_files_per_commitΒ²) which is sub-second for β€50k commits in the lookback window.
The Action posts an informational comment ("not enough git history in lookback window") and exits 0. No false failures on new repos. The threshold under which this kicks in is min_occurrences, which defaults to 3.
CODEOWNERS encodes static file-ownership: "this team reviews these paths." couplingguard encodes dynamic co-change risk: "these files have historically broken together." The two are complementary β couplingguard reads your CODEOWNERS file and suggests owners of coupled files who aren't already on the PR, on top of GitHub's normal review-request flow.
That's the primary use case. AI coding agents (Copilot, Claude Code, Cursor) routinely produce PRs touching 15-30 files at once. A human wrote the PR description, but no human held the entire change in their head as a unified mental model. couplingguard is the cheapest backstop: a comment that surfaces the files the agent should have touched but didn't.
- β Predict bugs (it's a historical signal, not a model)
- β Replace CODEOWNERS (complementary)
- β Modify your code (read-only on the working tree)
- β Send your code anywhere (analysis runs entirely on your runner)
- β Support Bitbucket or Azure DevOps in v0.1 (GitHub + GitLab only)
| couplingguard | CodeScene | code-maat | Danger.js | CODEOWNERS | |
|---|---|---|---|---|---|
| Posts a comment on every PR | β | β | β (CSV only) | βοΈ (write your own) | β |
| Normalized co-change scoring | β | β | β | β | β |
| Suggests reviewers from CODEOWNERS | β | β | β | βοΈ | β |
| Optional CI failure gate | β | β | β | βοΈ | β |
Re-push delta line (π‘ 0.45 β π΄ 0.82) |
β | β | β | β | β |
| GitLab CI support | β | β | β | β | β |
| No hosted service / no signup | β | β | β | β | β |
| Open source | β MIT | β commercial | β GPLv3 | β MIT | (native GitHub feature) |
| Cost | Free | Per-seat license | Free | Free | Free |
| Install effort | 5 lines YAML | Hosted onboarding | CLI + scripting | Framework + scripts | One file |
Bottom line. CODEOWNERS encodes static ownership; couplingguard adds dynamic co-change signal. They're complementary β couplingguard uses CODEOWNERS to suggest better reviewers for the files historically coupled to your PR's files. code-maat (the original normalized co-change CLI from Adam Tornhill's Your Code as a Crime Scene) runs after the fact; couplingguard runs at PR open, when the fix is still cheap.
Known constraints in v0.1.1:
- Shallow clones are rejected. Detected and surfaced as error E001 with an actionable message. Add
fetch-depth: 0(GitHub) orGIT_DEPTH: "0"(GitLab). - PR file cap at 200. PRs touching more than 200 files are truncated with a warning. The pairs analysis is O(200 Γ matrix_size), so this is a deliberate ceiling.
- No auto-commit of dashboard files.
publish_dashboard: trueproduces an artifact; pushing the score JSON back tomainfor badge updates is on the v0.2 roadmap. - GitLab self-managed not officially tested. Should work via
CI_SERVER_URLbut only verified against gitlab.com. - Bitbucket / Azure DevOps β not supported yet. Open an issue if you want to vote it up the roadmap.
See CONTRIBUTING.md. Bugs β Issues. Security β SECURITY.md.
MIT. See LICENSE.
