|
| 1 | +# verify-backport |
| 2 | + |
| 3 | +Verifies that a backport branch carries commits whose content is identical to their |
| 4 | +counterparts on the main branch. |
| 5 | + |
| 6 | +## Problem |
| 7 | + |
| 8 | +When commits are cherry-picked or rebased onto a release branch, the commit hashes change. |
| 9 | +Cherry-pick trailers (`cherry picked from commit ...`) may be missing or incorrect. |
| 10 | +This tool provides **ex-post verification** that the backport commits match their originals |
| 11 | +by comparing the actual patch content, not metadata. |
| 12 | + |
| 13 | +## How it works |
| 14 | + |
| 15 | +The tool uses `git patch-id --stable` to compute a content-based hash of each commit's diff. |
| 16 | +Two commits that introduce the same code changes produce the same patch-id, regardless of |
| 17 | +their commit hash, surrounding context line numbers, or whitespace differences. |
| 18 | + |
| 19 | +For each backport commit: |
| 20 | +1. Compute its patch-id |
| 21 | +2. Find a main-branch commit with the same patch-id |
| 22 | +3. Verify the backport commit message **contains** the original commit message |
| 23 | + (the backport may add extra text such as backport-specific notes) |
| 24 | + |
| 25 | +When a backport commit doesn't match by patch-id, the tool tries to find a likely |
| 26 | +counterpart on main by matching the commit subject line, and reports it as a |
| 27 | +closest match (`~>`) so the user knows which main commit to compare against. |
| 28 | + |
| 29 | +For unmatched commits, the tool performs a file-level comparison and classifies |
| 30 | +each file by the type of review effort required (see "Change classification" below). |
| 31 | + |
| 32 | +## Usage |
| 33 | + |
| 34 | +### Branch mode |
| 35 | + |
| 36 | +Point the tool at a local branch that is a checkout of a backport PR: |
| 37 | + |
| 38 | +```bash |
| 39 | +# Fetch the PR locally |
| 40 | +git fetch origin pull/1234/head:pr-1234 |
| 41 | + |
| 42 | +# Verify the backport (targeting release-4.18) |
| 43 | +bin/verify-backport \ |
| 44 | + --backport-branch pr-1234 \ |
| 45 | + --base-branch origin/release-4.18 |
| 46 | +``` |
| 47 | + |
| 48 | +This derives backport commits as `origin/release-4.18..pr-1234` and searches |
| 49 | +the last 100 commits on main for matches. |
| 50 | + |
| 51 | +### Range mode |
| 52 | + |
| 53 | +Specify explicit git revision ranges for full control: |
| 54 | + |
| 55 | +```bash |
| 56 | +bin/verify-backport \ |
| 57 | + --backport-range release-4.18~5..release-4.18 \ |
| 58 | + --main-range main~20..main |
| 59 | +``` |
| 60 | + |
| 61 | +### Flags |
| 62 | + |
| 63 | +| Flag | Default | Description | |
| 64 | +|------|---------|-------------| |
| 65 | +| `--backport-branch` | | Local branch name (pseudo-PR mode) | |
| 66 | +| `--base-branch` | | Branch the backport is based on (e.g. `release-4.18`); required with `--backport-branch` | |
| 67 | +| `--backport-range` | | Git revision range for backport commits | |
| 68 | +| `--main-range` | | Git revision range for main commits | |
| 69 | +| `--main-branch` | `main` | Name of the main branch where originals live | |
| 70 | +| `--main-depth` | `100` | How many main commits to search (branch mode) | |
| 71 | +| `--check-missing` | `false` | Report main commits with no backport counterpart | |
| 72 | +| `--verbose` | `false` | Show structured diff for unmatched commits | |
| 73 | +| `--color` | `true` | Enable colored output (auto-disabled when not a terminal) | |
| 74 | +| `--json` | `false` | Output results as JSON (for scripting and LLM consumption) | |
| 75 | + |
| 76 | +Exactly one of `--backport-branch` or `--backport-range` must be provided. |
| 77 | +When using `--backport-branch`, `--base-branch` is required. |
| 78 | +When using `--backport-range`, `--main-range` is required. |
| 79 | + |
| 80 | +## Output |
| 81 | + |
| 82 | +### Default mode |
| 83 | + |
| 84 | +Each backport commit gets one status line: |
| 85 | + |
| 86 | +``` |
| 87 | +MATCH: abc1234 Fix the widget <--> def5678 |
| 88 | +MISMATCH-MESSAGE: abc1234 Update docs <--> def5678 |
| 89 | +UNMATCHED: abc1234 Adapted change (~> def5678) [2 identical, 1 moved, 1 additive, 1 edits] |
| 90 | + identical (2): |
| 91 | + internal/controller/nro_controller.go -> controllers/nro_controller.go (renamed) |
| 92 | + pkg/status/status_test.go |
| 93 | + moved (1): |
| 94 | + machineconfigpool.go -> rte.go (95% overlap) |
| 95 | + additive (1): |
| 96 | + pkg/status/status.go (+1 only in backport) |
| 97 | + edits (1): |
| 98 | + test/e2e/serial/tests/configuration.go (+4 only in backport, +2 only in main) |
| 99 | +``` |
| 100 | + |
| 101 | +The `~>` arrow indicates a closest match found by commit subject. The bracketed tag |
| 102 | +shows the reviewer triage summary. Files are grouped by change type (see below). |
| 103 | + |
| 104 | +With `--check-missing`, main commits with no backport counterpart are also shown: |
| 105 | + |
| 106 | +``` |
| 107 | +MISSING-BACKPORT: def5678 Original commit subject |
| 108 | +``` |
| 109 | + |
| 110 | +### Verbose mode (`--verbose`) |
| 111 | + |
| 112 | +Adds the actual differing lines for `additive` and `edits` files, and residual (non-overlapping) |
| 113 | +lines for `moved` files. |
| 114 | + |
| 115 | +``` |
| 116 | + edits (1): |
| 117 | + test/e2e/serial/tests/configuration.go (+4 only in backport, +2 only in main) |
| 118 | + only in backport: |
| 119 | + + import "github.com/k8stopologyawareschedwg/deployer/pkg/deployer/platform" |
| 120 | + + cond := status.FindCondition(updatedOperObj.Status.Conditions, status.ConditionAvailable) |
| 121 | + ... |
| 122 | + only in main: |
| 123 | + + dss, err := objects.GetDaemonSetsByNamespacedName(fxt.Client, ctx, ...) |
| 124 | + ... |
| 125 | +``` |
| 126 | + |
| 127 | +### JSON mode (`--json`) |
| 128 | + |
| 129 | +Outputs a single JSON object to stdout, suitable for scripting or LLM consumption. |
| 130 | +No color codes, no padding, no verbose preamble — just structured data. |
| 131 | + |
| 132 | +```json |
| 133 | +{ |
| 134 | + "summary": { |
| 135 | + "matched": 3, |
| 136 | + "total": 4, |
| 137 | + "unmatched_main": 1 |
| 138 | + }, |
| 139 | + "results": [ |
| 140 | + { |
| 141 | + "status": "MATCH", |
| 142 | + "backport": {"hash": "abc1234...", "subject": "Fix the widget"}, |
| 143 | + "main": {"hash": "def5678...", "subject": "Fix the widget"} |
| 144 | + }, |
| 145 | + { |
| 146 | + "status": "UNMATCHED", |
| 147 | + "backport": {"hash": "aaa1111...", "subject": "Adapted change"}, |
| 148 | + "closest": {"hash": "bbb2222...", "subject": "Adapted change"}, |
| 149 | + "patch_comparison": { |
| 150 | + "identical": 2, |
| 151 | + "moved": 1, |
| 152 | + "additive": 1, |
| 153 | + "edits": 0 |
| 154 | + } |
| 155 | + } |
| 156 | + ], |
| 157 | + "missing_backports": [ |
| 158 | + {"hash": "ccc3333...", "subject": "Original commit subject"} |
| 159 | + ] |
| 160 | +} |
| 161 | +``` |
| 162 | + |
| 163 | +Field presence rules: |
| 164 | +- `main`: present only for `MATCH` and `MISMATCH-MESSAGE` results |
| 165 | +- `closest` and `patch_comparison`: present only for `UNMATCHED` results with a subject match |
| 166 | +- `missing_backports`: present only when `--check-missing` is set and there are unmatched main commits |
| 167 | + |
| 168 | +#### LLM token efficiency estimate |
| 169 | + |
| 170 | +Without this tool, an LLM verifying a 10-commit backport against 100 main commits needs |
| 171 | +to run ~4 git commands (log + patch-id for each side), ingest their output (~4000-6000 |
| 172 | +tokens of raw git data), and cross-reference patch-ids and messages in-context. |
| 173 | + |
| 174 | +With `--json`, it is a single command producing ~500-800 tokens of pre-correlated output. |
| 175 | +The estimated saving is **3-5x fewer tokens** for the ingestion step, plus elimination of |
| 176 | +the multi-step reasoning needed to correlate commits manually. The exit code alone (0 vs 2) |
| 177 | +answers "is this backport correct?" without parsing any output at all. |
| 178 | + |
| 179 | +These estimates are back-of-envelope (May 2026) and should be verified against actual |
| 180 | +token counts in representative backport reviews. |
| 181 | + |
| 182 | +### Change classification |
| 183 | + |
| 184 | +Files are classified by the type of review effort they require, from lowest to highest bandwidth: |
| 185 | + |
| 186 | +| Category | What it means | How to review | Color | |
| 187 | +|----------|---------------|---------------|-------| |
| 188 | +| **identical** | No differing lines (with or without rename) | Skip entirely | dim | |
| 189 | +| **moved** | File relocated between releases (>=50% content overlap) | Glance at residual lines (shown in verbose) | dim | |
| 190 | +| **additive** | Only one side has extra lines | Check that additions/deletions make sense for the target branch | yellow | |
| 191 | +| **edits** | Both sides have differing lines | Full review: substantive changes were made | yellow | |
| 192 | + |
| 193 | +This classification tells you **how** to review each file, not just whether to review it. |
| 194 | + |
| 195 | +## Exit codes |
| 196 | + |
| 197 | +| Code | Meaning | |
| 198 | +|------|---------| |
| 199 | +| 0 | All backport commits match their main counterparts | |
| 200 | +| 1 | Usage error or runtime failure (bad flags, git command error) | |
| 201 | +| 2 | Verification failure (mismatches, unmatched, or missing backports) | |
| 202 | + |
| 203 | +## Design notes |
| 204 | + |
| 205 | +### Architecture |
| 206 | + |
| 207 | +The tool operates in three phases: |
| 208 | + |
| 209 | +1. **Commit-level matching**: Compute patch-ids for backport and main commits; match by patch-id or fall back to subject-line matching |
| 210 | +2. **File-level comparison**: For each unmatched commit pair, parse both patches and extract per-file added/removed lines. **Whitespace is intentionally treated as non-semantic**: lines are trimmed before comparison, so pure whitespace/indentation differences are not reported as edits. This matches the behavior of `git patch-id --stable` and avoids drowning reviewers in noise from reformatting or tab/space changes between branches. |
| 211 | +3. **Change classification**: Classify each file by change type (identical/moved/additive/edits) and present triage-friendly output |
| 212 | + |
| 213 | +### Code movement detection |
| 214 | + |
| 215 | +After pairing files by path and basename, leftover ONLY-IN-BACKPORT and ONLY-IN-MAIN files |
| 216 | +are tested for code movement using set intersection of their added lines: |
| 217 | + |
| 218 | +``` |
| 219 | +overlap = |intersection(bpAdded, mainAdded)| / min(|bpAdded|, |mainAdded|) |
| 220 | +``` |
| 221 | + |
| 222 | +If overlap >= 50%, the files are classified as a `moved` pair. The `min` denominator (not union) |
| 223 | +ensures that a small file fully contained in a larger one still registers as movement. |
| 224 | + |
| 225 | +**Why this is reliable:** |
| 226 | +- Compares actual code content, not filenames or heuristics |
| 227 | +- False positives (unrelated files sharing 50%+ of added lines) are extremely unlikely in practice |
| 228 | +- Real-world backports show near-100% overlap for genuine code movement (e.g., refactoring between releases) |
| 229 | + |
| 230 | +**Scope constraint:** Code movement is detected only within each commit. If code moves across |
| 231 | +commits within the same backport, that's a red flag indicating the backport diverged from the |
| 232 | +original structure, which itself is useful signal. |
| 233 | + |
| 234 | +### Why patch-id for commit matching |
| 235 | + |
| 236 | +`git patch-id` is purpose-built for detecting equivalent patches across rebases and cherry-picks. |
| 237 | +It normalizes away: |
| 238 | +- Context line numbers (hunks can shift without changing the patch-id) |
| 239 | +- Whitespace differences |
| 240 | +- Commit metadata (author, date, hash) |
| 241 | + |
| 242 | +But it remains sensitive to: |
| 243 | +- Actual code changes (even one character difference produces a different patch-id) |
| 244 | +- File renames (a renamed file produces a different patch) |
| 245 | + |
| 246 | +This is the right level of strictness: we want to catch any substantive changes while allowing |
| 247 | +mechanical differences like directory restructuring or import path updates. |
| 248 | + |
| 249 | +### Design constraints |
| 250 | + |
| 251 | +- **Local-only, no network access**: The tool **never contacts remote servers**. All git operations run against the local clone using `exec.Command`. It does not fetch, push, or call any API (GitHub or otherwise). This is a deliberate trust boundary: the tool's output depends solely on what is already checked out locally, so there is no risk of leaking credentials, hitting rate limits, or trusting data from an external service. The user controls what is in the local clone; the tool only reads it. |
| 252 | +- **No external dependencies beyond git**: The tool uses only stdlib + `golang.org/x/term` (for color detection). No go-git, no GitHub API. |
| 253 | +- **Stateless**: Each run is independent; no caching or persistent state between runs. |
| 254 | +- **Colored by default**: Color is a high-ROI UX improvement for dense output. Auto-disabled when not a terminal or when `--color=false`. |
| 255 | + |
| 256 | +### Future iteration notes |
| 257 | + |
| 258 | +- **Cross-commit movement detection**: Currently not implemented (by design). If needed, pool all ONLY-IN-BACKPORT and ONLY-IN-MAIN files across all commits and match globally. But this may hide backport structure divergence, so default should remain within-commit. |
| 259 | +- **Mechanical change detection**: Import path substitutions (e.g., `api/numaresourcesoperator/v1` → `api/v1`) are currently shown as diffs. Could detect and label these as "mechanical" to further reduce reviewer bandwidth. |
| 260 | +- **Diff output format**: Currently shows +/- lines inline. Could add side-by-side or unified diff views. |
| 261 | +- **Performance**: For very large backport PRs (50+ commits), computing patch-ids and parsing patches is the bottleneck. Could parallelize per-commit processing if needed. |
| 262 | + |
| 263 | +## Key assumptions |
| 264 | + |
| 265 | +- **Local clone required, no network access**: The tool operates exclusively on a local git |
| 266 | + repository and **never makes network calls**. Both the backport branch/range and the main |
| 267 | + branch must be fetched locally before running the tool. This eliminates any need to trust |
| 268 | + remote services or configure authentication. |
| 269 | +- **No merge commits**: Merge commits are excluded from comparison (`--no-merges`) since |
| 270 | + they don't carry meaningful patch content. |
| 271 | +- **Message containment, not equality**: The backport commit message must *contain* the |
| 272 | + main commit message as a substring. This allows backport-specific additions (e.g., |
| 273 | + "Backport to release-4.18" notes) while ensuring the original message is preserved. |
| 274 | +- **Whitespace normalization**: Trailing whitespace per line and trailing newlines are |
| 275 | + normalized before message comparison. |
| 276 | +- **Patch-id stability**: `git patch-id --stable` is used for reproducible results |
| 277 | + across git versions. |
| 278 | +- **Closest match by subject**: When patch-ids don't match, the tool falls back to |
| 279 | + matching by commit subject line to suggest a likely counterpart for investigation. |
| 280 | + |
| 281 | +## Building |
| 282 | + |
| 283 | +```bash |
| 284 | +make bin/verify-backport |
| 285 | +``` |
| 286 | + |
| 287 | +## Requirements |
| 288 | + |
| 289 | +- Git (any reasonably modern version with `patch-id --stable` support, i.e., >= 2.0) |
| 290 | +- Go 1.25+ (for building) |
0 commit comments