|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +import argparse |
| 4 | +import subprocess |
| 5 | +import re |
| 6 | +import sys |
| 7 | +import os |
| 8 | +import tempfile |
| 9 | + |
| 10 | +def run_git(repo, args): |
| 11 | + """Run a git command in the given repository and return its output as a string.""" |
| 12 | + result = subprocess.run(['git', '-C', repo] + args, text=True, capture_output=True, check=False) |
| 13 | + if result.returncode != 0: |
| 14 | + raise RuntimeError(f"Git command failed: {' '.join(args)}\n{result.stderr}") |
| 15 | + return result.stdout |
| 16 | + |
| 17 | +def ref_exists(repo, ref): |
| 18 | + """Return True if the given ref exists in the repository, False otherwise.""" |
| 19 | + try: |
| 20 | + run_git(repo, ['rev-parse', '--verify', '--quiet', ref]) |
| 21 | + return True |
| 22 | + except RuntimeError: |
| 23 | + return False |
| 24 | + |
| 25 | +def get_pr_commits(repo, pr_branch, base_branch): |
| 26 | + """Get a list of commit SHAs that are in the PR branch but not in the base branch.""" |
| 27 | + try: |
| 28 | + output = run_git(repo, ['rev-list', f'{base_branch}..{pr_branch}']) |
| 29 | + return output.strip().splitlines() |
| 30 | + except RuntimeError as e: |
| 31 | + raise RuntimeError(f"Failed to get commits from {base_branch}..{pr_branch}: {e}") |
| 32 | + |
| 33 | +def get_commit_message(repo, sha): |
| 34 | + """Get the commit message for a given commit SHA.""" |
| 35 | + try: |
| 36 | + return run_git(repo, ['log', '-n', '1', '--format=%B', sha]) |
| 37 | + except RuntimeError as e: |
| 38 | + raise RuntimeError(f"Failed to get commit message for {sha}: {e}") |
| 39 | + |
| 40 | +def get_short_hash_and_subject(repo, sha): |
| 41 | + """Get the abbreviated commit hash and subject for a given commit SHA.""" |
| 42 | + try: |
| 43 | + output = run_git(repo, ['log', '-n', '1', '--format=%h%x00%s', sha]).strip() |
| 44 | + short_hash, subject = output.split('\x00', 1) |
| 45 | + return short_hash, subject |
| 46 | + except RuntimeError as e: |
| 47 | + raise RuntimeError(f"Failed to get short hash and subject for {sha}: {e}") |
| 48 | + |
| 49 | +def extract_upstream_hash(msg): |
| 50 | + """Extract the upstream commit hash from a commit message. |
| 51 | + Looks for lines like 'commit <hash>' in the commit message.""" |
| 52 | + match = re.search(r'^commit\s+([0-9a-fA-F]{12,40})', msg, re.MULTILINE) |
| 53 | + if match: |
| 54 | + return match.group(1) |
| 55 | + return None |
| 56 | + |
| 57 | +def run_interdiff(repo, backport_sha, upstream_sha, interdiff_path): |
| 58 | + """Run interdiff comparing the backport commit with the upstream commit. |
| 59 | + Returns (success, output) tuple.""" |
| 60 | + # Generate format-patch for backport commit |
| 61 | + try: |
| 62 | + backport_patch = run_git(repo, ['format-patch', '-1', '--stdout', backport_sha]) |
| 63 | + except RuntimeError as e: |
| 64 | + return False, f"Failed to generate patch for backport commit: {e}" |
| 65 | + |
| 66 | + # Generate format-patch for upstream commit |
| 67 | + try: |
| 68 | + upstream_patch = run_git(repo, ['format-patch', '-1', '--stdout', upstream_sha]) |
| 69 | + except RuntimeError as e: |
| 70 | + return False, f"Failed to generate patch for upstream commit: {e}" |
| 71 | + |
| 72 | + # Write patches to temp files |
| 73 | + bp_path = None |
| 74 | + up_path = None |
| 75 | + try: |
| 76 | + with tempfile.NamedTemporaryFile(mode='w', suffix='.patch', delete=False) as bp: |
| 77 | + bp.write(backport_patch) |
| 78 | + bp_path = bp.name |
| 79 | + |
| 80 | + with tempfile.NamedTemporaryFile(mode='w', suffix='.patch', delete=False) as up: |
| 81 | + up.write(upstream_patch) |
| 82 | + up_path = up.name |
| 83 | + |
| 84 | + interdiff_result = subprocess.run( |
| 85 | + [interdiff_path, '--fuzzy', bp_path, up_path], |
| 86 | + text=True, |
| 87 | + capture_output=True, |
| 88 | + check=False |
| 89 | + ) |
| 90 | + |
| 91 | + # Check for interdiff errors (non-zero return code other than 1) |
| 92 | + # Note: interdiff returns 0 if no differences, 1 if differences found |
| 93 | + if interdiff_result.returncode not in (0, 1): |
| 94 | + if interdiff_result.stderr: |
| 95 | + error_msg = interdiff_result.stderr.strip() |
| 96 | + else: |
| 97 | + error_msg = f"Exit code {interdiff_result.returncode}" |
| 98 | + return False, f"interdiff failed: {error_msg}" |
| 99 | + |
| 100 | + return True, interdiff_result.stdout.strip() |
| 101 | + except Exception as e: |
| 102 | + return False, f"Failed to run interdiff: {e}" |
| 103 | + finally: |
| 104 | + # Clean up temp files if they were created |
| 105 | + if bp_path and os.path.exists(bp_path): |
| 106 | + os.unlink(bp_path) |
| 107 | + if up_path and os.path.exists(up_path): |
| 108 | + os.unlink(up_path) |
| 109 | + |
| 110 | +def find_interdiff(): |
| 111 | + """Find interdiff in system PATH. Returns path if found, None otherwise.""" |
| 112 | + result = subprocess.run(['which', 'interdiff'], capture_output=True, text=True, check=False) |
| 113 | + if result.returncode == 0: |
| 114 | + return result.stdout.strip() |
| 115 | + return None |
| 116 | + |
| 117 | +def main(): |
| 118 | + parser = argparse.ArgumentParser( |
| 119 | + description="Run interdiff on backported kernel commits to compare with upstream." |
| 120 | + ) |
| 121 | + parser.add_argument("--repo", help="Path to the Linux kernel git repo", required=True) |
| 122 | + parser.add_argument("--pr_branch", help="Git reference to the feature branch", required=True) |
| 123 | + parser.add_argument("--base_branch", help="Branch the feature branch is based off of", required=True) |
| 124 | + parser.add_argument("--markdown", action='store_true', help="Format output with markdown") |
| 125 | + parser.add_argument("--interdiff", help="Path to interdiff executable (default: system interdiff)", default=None) |
| 126 | + args = parser.parse_args() |
| 127 | + |
| 128 | + # Determine interdiff path |
| 129 | + if args.interdiff: |
| 130 | + # User specified a path |
| 131 | + interdiff_path = args.interdiff |
| 132 | + if not os.path.exists(interdiff_path): |
| 133 | + print(f"ERROR: interdiff not found at specified path: {interdiff_path}") |
| 134 | + sys.exit(1) |
| 135 | + if not os.access(interdiff_path, os.X_OK): |
| 136 | + print(f"ERROR: interdiff at {interdiff_path} is not executable") |
| 137 | + sys.exit(1) |
| 138 | + else: |
| 139 | + # Try to find system interdiff |
| 140 | + interdiff_path = find_interdiff() |
| 141 | + if not interdiff_path: |
| 142 | + print("ERROR: interdiff not found in system PATH") |
| 143 | + print("Please install patchutils or specify path with --interdiff") |
| 144 | + sys.exit(1) |
| 145 | + |
| 146 | + # Validate that all required refs exist |
| 147 | + missing_refs = [] |
| 148 | + for refname, refval in [('PR branch', args.pr_branch), |
| 149 | + ('base branch', args.base_branch)]: |
| 150 | + if not ref_exists(args.repo, refval): |
| 151 | + missing_refs.append((refname, refval)) |
| 152 | + |
| 153 | + if missing_refs: |
| 154 | + for refname, refval in missing_refs: |
| 155 | + print(f"ERROR: The {refname} '{refval}' does not exist in the given repo.") |
| 156 | + print("Please fetch or create the required references before running this script.") |
| 157 | + sys.exit(1) |
| 158 | + |
| 159 | + # Get all PR commits |
| 160 | + pr_commits = get_pr_commits(args.repo, args.pr_branch, args.base_branch) |
| 161 | + if not pr_commits: |
| 162 | + if args.markdown: |
| 163 | + print("> ℹ️ **No commits found in PR branch that are not in base branch.**") |
| 164 | + else: |
| 165 | + print("No commits found in PR branch that are not in base branch.") |
| 166 | + sys.exit(0) |
| 167 | + |
| 168 | + any_differences = False |
| 169 | + out_lines = [] |
| 170 | + |
| 171 | + # Process commits in chronological order (oldest first) |
| 172 | + for sha in reversed(pr_commits): |
| 173 | + try: |
| 174 | + short_hash, subject = get_short_hash_and_subject(args.repo, sha) |
| 175 | + pr_commit_desc = f"{short_hash} ({subject})" |
| 176 | + |
| 177 | + msg = get_commit_message(args.repo, sha) |
| 178 | + upstream_hash = extract_upstream_hash(msg) |
| 179 | + except RuntimeError as e: |
| 180 | + # Handle errors getting commit information |
| 181 | + any_differences = True |
| 182 | + if args.markdown: |
| 183 | + out_lines.append(f"- ❌ PR commit `{sha[:12]}` → Error getting commit info") |
| 184 | + out_lines.append(f" **Error:** {e}\n") |
| 185 | + else: |
| 186 | + out_lines.append(f"[ERROR] PR commit {sha[:12]} → Error getting commit info") |
| 187 | + out_lines.append(f" {e}") |
| 188 | + out_lines.append("") |
| 189 | + continue |
| 190 | + |
| 191 | + # Only process commits that have an upstream reference |
| 192 | + if not upstream_hash: |
| 193 | + continue |
| 194 | + |
| 195 | + # Run interdiff |
| 196 | + success, output = run_interdiff(args.repo, sha, upstream_hash, interdiff_path) |
| 197 | + |
| 198 | + if not success: |
| 199 | + # Error running interdiff |
| 200 | + any_differences = True |
| 201 | + if args.markdown: |
| 202 | + out_lines.append(f"- ❌ PR commit `{pr_commit_desc}` → `{upstream_hash[:12]}`") |
| 203 | + out_lines.append(f" **Error:** {output}\n") |
| 204 | + else: |
| 205 | + out_lines.append(f"[ERROR] PR commit {pr_commit_desc} → {upstream_hash[:12]}") |
| 206 | + out_lines.append(f" {output}") |
| 207 | + out_lines.append("") |
| 208 | + elif output: |
| 209 | + # There are differences |
| 210 | + any_differences = True |
| 211 | + if args.markdown: |
| 212 | + out_lines.append(f"- ⚠️ PR commit `{pr_commit_desc}` → upstream `{upstream_hash[:12]}`") |
| 213 | + out_lines.append(f" **Differences found:**\n") |
| 214 | + out_lines.append("```diff") |
| 215 | + out_lines.append(output) |
| 216 | + out_lines.append("```\n") |
| 217 | + else: |
| 218 | + out_lines.append(f"[DIFF] PR commit {pr_commit_desc} → upstream {upstream_hash[:12]}") |
| 219 | + out_lines.append("Differences found:") |
| 220 | + out_lines.append("") |
| 221 | + for line in output.splitlines(): |
| 222 | + out_lines.append(" " + line) |
| 223 | + out_lines.append("") |
| 224 | + |
| 225 | + # Print results |
| 226 | + if any_differences: |
| 227 | + if args.markdown: |
| 228 | + print("## :mag: Interdiff Analysis\n") |
| 229 | + print('\n'.join(out_lines)) |
| 230 | + print("*This is an automated interdiff check for backported commits.*") |
| 231 | + else: |
| 232 | + print('\n'.join(out_lines)) |
| 233 | + else: |
| 234 | + if args.markdown: |
| 235 | + print("> ✅ **All backported commits match their upstream counterparts.**") |
| 236 | + else: |
| 237 | + print("All backported commits match their upstream counterparts.") |
| 238 | + |
| 239 | +if __name__ == "__main__": |
| 240 | + main() |
0 commit comments