req_merge
req_merge — auto-resolve one narrow class of git cherry-pick conflict: two dependency-bump
commits that touch a *requirements*.txt file on adjacent (or the same) lines, where every line
in the conflict hunk, on both sides, is a plain name>=x[,<y] pin. scripts/deps-stack.sh calls
this after a cherry-pick fails, on the file(s) git diff --name-only --diff-filter=U reports
conflicted, only when every one of them matches *requirements*.txt — anything else stops for a
human before this module ever runs (see that script's auto_resolve_requirements).
scripts/lib/req_merge.py <conflicted-file> [<conflicted-file> ...]
scripts/lib/req_merge.py --self-test
Resolution rule, per package name appearing in a hunk: keep the side with the higher lower bound
(>=, compared as a numeric version tuple, not a string), including that side's own ,<upper
marker if it has one — the other side's upper bound (if any) is dropped, since it belongs to a
lower, superseded pin. All-or-nothing per invocation: if any given file's conflict markers don't
reduce to this shape (a different file, a non-requirements hunk, a package on only one side, a
duplicate name on one side), nothing is written to any of the given files, and this exits 1 —
deps-stack.sh then leaves every file conflicted and prints the human-resolve instructions.
1#!/usr/bin/env python3 2"""req_merge — auto-resolve one narrow class of `git cherry-pick` conflict: two dependency-bump 3commits that touch a `*requirements*.txt` file on adjacent (or the same) lines, where every line 4in the conflict hunk, on both sides, is a plain `name>=x[,<y]` pin. `scripts/deps-stack.sh` calls 5this after a cherry-pick fails, on the file(s) `git diff --name-only --diff-filter=U` reports 6conflicted, only when every one of them matches `*requirements*.txt` — anything else stops for a 7human before this module ever runs (see that script's `auto_resolve_requirements`). 8 9 scripts/lib/req_merge.py <conflicted-file> [<conflicted-file> ...] 10 scripts/lib/req_merge.py --self-test 11 12Resolution rule, per package name appearing in a hunk: keep the side with the higher lower bound 13(`>=`, compared as a numeric version tuple, not a string), including that side's own `,<upper` 14marker if it has one — the *other* side's upper bound (if any) is dropped, since it belongs to a 15lower, superseded pin. All-or-nothing per invocation: if any given file's conflict markers don't 16reduce to this shape (a different file, a non-requirements hunk, a package on only one side, a 17duplicate name on one side), nothing is written to *any* of the given files, and this exits 1 — 18`deps-stack.sh` then leaves every file conflicted and prints the human-resolve instructions. 19""" 20 21import argparse 22import re 23import sys 24from pathlib import Path 25 26# Matches one bare-lower-bound (optionally upper-bounded) pin, the only shape dependabot's own 27# requirements.txt bumps produce: "name>=1.2.3" or "name>=1.2.3,<2.0.0". No `==`, no extras, no 28# comments — a line outside this shape means "not this conflict class", so the caller refuses. 29REQ_LINE = re.compile( 30 r"^\s*([A-Za-z0-9][A-Za-z0-9_.-]*)\s*>=\s*([0-9][0-9A-Za-z.]*)(\s*,\s*<\s*[0-9][0-9A-Za-z.]*)?\s*$" 31) 32 33# One conflict hunk: `<<<<<<< ours\n...\n[||||||| base\n...\n]=======\n...\n>>>>>>> theirs`. The 34# optional diff3 base section (`|||||||`) is matched but ignored — only ours/theirs decide the 35# resolution. 36CONFLICT_RE = re.compile( 37 r"<<<<<<<[^\n]*\n" 38 r"(?P<ours>.*?)\n" 39 r"(?:\|\|\|\|\|\|\|[^\n]*\n.*?\n)?" 40 r"=======\n" 41 r"(?P<theirs>.*?)\n" 42 r">>>>>>>[^\n]*", 43 re.DOTALL, 44) 45 46 47class Unresolvable(Exception): 48 pass 49 50 51def parse_version(v: str) -> tuple[int, ...]: 52 """ "1.2.3rc1" -> (1, 2, 3, 0) — numeric-only, non-digit suffixes fall back to 0 for that 53 segment so a comparison never raises; good enough for "which bump is newer", not a PEP 440 54 parser.""" 55 parts = [] 56 for seg in v.split("."): 57 m = re.match(r"\d+", seg) 58 parts.append(int(m.group()) if m else 0) 59 return tuple(parts) 60 61 62def parse_requirement(line: str) -> tuple[str, str, str] | None: 63 """ "numpy>=1.24.0,<2.0.0" -> ("numpy", "1.24.0", ",<2.0.0"); None if the line isn't this 64 exact shape.""" 65 m = REQ_LINE.match(line) 66 if not m: 67 return None 68 name, lower, upper = m.group(1), m.group(2), m.group(3) or "" 69 upper = re.sub(r"\s+", "", upper) # normalize " , < 2.0.0" -> ",<2.0.0" 70 return name, lower, upper 71 72 73def resolve_hunk(ours_lines: list[str], theirs_lines: list[str]) -> tuple[list[str], list[str]]: 74 """Merge one conflict hunk's ours/theirs line lists, or raise Unresolvable. Returns the 75 merged lines (ours' order) and one log line per package whose bound actually differed.""" 76 ours = [line for line in ours_lines if line.strip()] 77 theirs = [line for line in theirs_lines if line.strip()] 78 79 def to_map(lines: list[str]) -> dict[str, tuple[str, str]]: 80 out: dict[str, tuple[str, str]] = {} 81 for line in lines: 82 parsed = parse_requirement(line) 83 if parsed is None: 84 raise Unresolvable(f"not a plain name>=version pin: {line!r}") 85 name, lower, upper = parsed 86 if name in out: 87 raise Unresolvable(f"{name} appears twice on one side of the conflict") 88 out[name] = (lower, upper) 89 return out 90 91 ours_map = to_map(ours) 92 theirs_map = to_map(theirs) 93 if set(ours_map) != set(theirs_map): 94 only = set(ours_map) ^ set(theirs_map) 95 raise Unresolvable( 96 f"package(s) on only one side of the conflict: {', '.join(sorted(only))}" 97 ) 98 99 order = [parse_requirement(line)[0] for line in ours] # type: ignore[index] 100 merged: list[str] = [] 101 log: list[str] = [] 102 for name in order: 103 o_lower, o_upper = ours_map[name] 104 t_lower, t_upper = theirs_map[name] 105 if parse_version(o_lower) >= parse_version(t_lower): 106 kept_lower, kept_upper, other_lower = o_lower, o_upper, t_lower 107 else: 108 kept_lower, kept_upper, other_lower = t_lower, t_upper, o_lower 109 merged.append(f"{name}>={kept_lower}{kept_upper}") 110 if o_lower != t_lower: 111 log.append(f"{name}: kept >={kept_lower}{kept_upper} over >={other_lower}") 112 return merged, log 113 114 115def merge_text(text: str) -> tuple[str, list[str]]: 116 """The whole file's text -> (merged text, log lines), or raise Unresolvable. A no-op 117 (already-clean) file returns unchanged with no log lines.""" 118 if "<<<<<<<" not in text: 119 return text, [] 120 log: list[str] = [] 121 122 def repl(m: re.Match[str]) -> str: 123 ours = m.group("ours").split("\n") if m.group("ours") else [] 124 theirs = m.group("theirs").split("\n") if m.group("theirs") else [] 125 lines, hunk_log = resolve_hunk(ours, theirs) 126 log.extend(hunk_log) 127 return "\n".join(lines) 128 129 merged = CONFLICT_RE.sub(repl, text) 130 if "<<<<<<<" in merged or ">>>>>>>" in merged: 131 raise Unresolvable("leftover conflict marker after substitution — unexpected shape") 132 return merged, log 133 134 135def resolve_files(paths: list[Path]) -> list[str]: 136 """All-or-nothing: every file must resolve, or nothing is written. Returns the log lines 137 (prefixed with the file), in argument order. Raises Unresolvable (naming the offending file) 138 on the first file that doesn't reduce to a pure requirements-bound conflict.""" 139 results: dict[Path, tuple[str, list[str]]] = {} 140 for path in paths: 141 text = path.read_text() 142 try: 143 merged, log = merge_text(text) 144 except Unresolvable as exc: 145 raise Unresolvable(f"{path}: {exc}") from exc 146 results[path] = (merged, log) 147 all_log: list[str] = [] 148 for path, (merged, log) in results.items(): 149 path.write_text(merged) 150 all_log.extend(f"{path}: {line}" for line in log) 151 return all_log 152 153 154# --- self-test ---------------------------------------------------------------------------------- 155 156 157def self_test() -> int: 158 # 1. Adjacent-line conflict: two *different* packages, each bumped by a different commit, on 159 # lines close enough that git's diff3 folds them into one hunk (the reported real-world 160 # case: two dependabot PRs touching adjacent lines of finetune/requirements.txt). Each 161 # package is "changed" on one side and merely present-as-context on the other, so it still 162 # appears on both sides of the hunk with the same package-name set. 163 adjacent = ( 164 "flask>=2.0.0\n" 165 "<<<<<<< HEAD\n" 166 "numpy>=1.24.0\n" 167 "pandas>=1.5.0\n" 168 "=======\n" 169 "numpy>=1.20.0\n" 170 "pandas>=1.6.0\n" 171 ">>>>>>> dependabot/pip/pandas-1.6.0\n" 172 "requests>=2.28.0\n" 173 ) 174 merged, log = merge_text(adjacent) 175 assert "numpy>=1.24.0" in merged, merged # ours' bump of numpy kept (1.24.0 > 1.20.0) 176 assert "pandas>=1.6.0" in merged, merged # theirs' bump of pandas kept (1.6.0 > 1.5.0) 177 assert "<<<<<<<" not in merged and ">>>>>>>" not in merged, merged 178 assert len(log) == 2, log 179 180 # 2. Same package on both sides, different bounds -> take the higher lower bound. 181 same_pkg = "<<<<<<< HEAD\nnumpy>=1.20.0\n=======\nnumpy>=1.26.0\n>>>>>>> theirs\n" 182 merged2, log2 = merge_text(same_pkg) 183 assert merged2.strip() == "numpy>=1.26.0", merged2 184 assert len(log2) == 1 and "1.26.0" in log2[0], log2 185 186 # 3. Upper bound preserved from whichever side has the higher lower bound, and only that side. 187 upper_kept = "<<<<<<< HEAD\nnumpy>=1.28.0,<2.0.0\n=======\nnumpy>=1.26.0\n>>>>>>> theirs\n" 188 merged3, _ = merge_text(upper_kept) 189 assert merged3.strip() == "numpy>=1.28.0,<2.0.0", merged3 190 191 upper_dropped = "<<<<<<< HEAD\nnumpy>=1.24.0,<2.0.0\n=======\nnumpy>=1.26.0\n>>>>>>> theirs\n" 192 merged4, _ = merge_text(upper_dropped) 193 assert merged4.strip() == "numpy>=1.26.0", merged4 # theirs wins, and theirs has no upper 194 195 # 4. A package on only one side -> refuse (a real dependency add/remove, not a pure bump). 196 only_one_side = ( 197 "<<<<<<< HEAD\nnumpy>=1.24.0\nscipy>=1.10.0\n=======\nnumpy>=1.26.0\n>>>>>>> theirs\n" 198 ) 199 try: 200 merge_text(only_one_side) 201 raise AssertionError("expected Unresolvable for a package on only one side") 202 except Unresolvable as exc: 203 assert "scipy" in str(exc), exc 204 205 # 5. Not a requirements-shaped hunk at all -> refuse. 206 not_reqs = "<<<<<<< HEAD\nsome random text\n=======\nother text\n>>>>>>> theirs\n" 207 try: 208 merge_text(not_reqs) 209 raise AssertionError("expected Unresolvable for a non-requirements hunk") 210 except Unresolvable: 211 pass 212 213 # 6. resolve_files: all-or-nothing across two files, via the filesystem. 214 import tempfile 215 216 with tempfile.TemporaryDirectory() as tmp: 217 d = Path(tmp) 218 good = d / "requirements.txt" 219 good.write_text(same_pkg) 220 bad = d / "other-requirements.txt" 221 bad.write_text(not_reqs) 222 try: 223 resolve_files([good, bad]) 224 raise AssertionError("expected Unresolvable when one of two files can't resolve") 225 except Unresolvable: 226 pass 227 # neither file was touched — atomic, all-or-nothing 228 assert good.read_text() == same_pkg 229 assert bad.read_text() == not_reqs 230 231 bad.write_text(adjacent) 232 log_lines = resolve_files([good, bad]) 233 assert good.read_text().strip() == "numpy>=1.26.0" 234 assert "<<<<<<<" not in bad.read_text() 235 assert len(log_lines) == 3, log_lines # 1 from `good`, 2 from `bad` 236 237 print("req_merge self-test: PASSED") 238 return 0 239 240 241def main(argv: list[str]) -> int: 242 ap = argparse.ArgumentParser( 243 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 244 ) 245 ap.add_argument("files", nargs="*", type=Path) 246 ap.add_argument("--self-test", action="store_true") 247 args = ap.parse_args(argv) 248 if args.self_test: 249 return self_test() 250 if not args.files: 251 ap.print_help() 252 return 2 253 try: 254 for line in resolve_files(args.files): 255 print(line) 256 except Unresolvable as exc: 257 print(f"req_merge: cannot auto-resolve — {exc}", file=sys.stderr) 258 return 1 259 return 0 260 261 262if __name__ == "__main__": 263 sys.exit(main(sys.argv[1:]))
Common base class for all non-exit exceptions.
52def parse_version(v: str) -> tuple[int, ...]: 53 """ "1.2.3rc1" -> (1, 2, 3, 0) — numeric-only, non-digit suffixes fall back to 0 for that 54 segment so a comparison never raises; good enough for "which bump is newer", not a PEP 440 55 parser.""" 56 parts = [] 57 for seg in v.split("."): 58 m = re.match(r"\d+", seg) 59 parts.append(int(m.group()) if m else 0) 60 return tuple(parts)
"1.2.3rc1" -> (1, 2, 3, 0) — numeric-only, non-digit suffixes fall back to 0 for that segment so a comparison never raises; good enough for "which bump is newer", not a PEP 440 parser.
63def parse_requirement(line: str) -> tuple[str, str, str] | None: 64 """ "numpy>=1.24.0,<2.0.0" -> ("numpy", "1.24.0", ",<2.0.0"); None if the line isn't this 65 exact shape.""" 66 m = REQ_LINE.match(line) 67 if not m: 68 return None 69 name, lower, upper = m.group(1), m.group(2), m.group(3) or "" 70 upper = re.sub(r"\s+", "", upper) # normalize " , < 2.0.0" -> ",<2.0.0" 71 return name, lower, upper
"numpy>=1.24.0,<2.0.0" -> ("numpy", "1.24.0", ",<2.0.0"); None if the line isn't this exact shape.
74def resolve_hunk(ours_lines: list[str], theirs_lines: list[str]) -> tuple[list[str], list[str]]: 75 """Merge one conflict hunk's ours/theirs line lists, or raise Unresolvable. Returns the 76 merged lines (ours' order) and one log line per package whose bound actually differed.""" 77 ours = [line for line in ours_lines if line.strip()] 78 theirs = [line for line in theirs_lines if line.strip()] 79 80 def to_map(lines: list[str]) -> dict[str, tuple[str, str]]: 81 out: dict[str, tuple[str, str]] = {} 82 for line in lines: 83 parsed = parse_requirement(line) 84 if parsed is None: 85 raise Unresolvable(f"not a plain name>=version pin: {line!r}") 86 name, lower, upper = parsed 87 if name in out: 88 raise Unresolvable(f"{name} appears twice on one side of the conflict") 89 out[name] = (lower, upper) 90 return out 91 92 ours_map = to_map(ours) 93 theirs_map = to_map(theirs) 94 if set(ours_map) != set(theirs_map): 95 only = set(ours_map) ^ set(theirs_map) 96 raise Unresolvable( 97 f"package(s) on only one side of the conflict: {', '.join(sorted(only))}" 98 ) 99 100 order = [parse_requirement(line)[0] for line in ours] # type: ignore[index] 101 merged: list[str] = [] 102 log: list[str] = [] 103 for name in order: 104 o_lower, o_upper = ours_map[name] 105 t_lower, t_upper = theirs_map[name] 106 if parse_version(o_lower) >= parse_version(t_lower): 107 kept_lower, kept_upper, other_lower = o_lower, o_upper, t_lower 108 else: 109 kept_lower, kept_upper, other_lower = t_lower, t_upper, o_lower 110 merged.append(f"{name}>={kept_lower}{kept_upper}") 111 if o_lower != t_lower: 112 log.append(f"{name}: kept >={kept_lower}{kept_upper} over >={other_lower}") 113 return merged, log
Merge one conflict hunk's ours/theirs line lists, or raise Unresolvable. Returns the merged lines (ours' order) and one log line per package whose bound actually differed.
116def merge_text(text: str) -> tuple[str, list[str]]: 117 """The whole file's text -> (merged text, log lines), or raise Unresolvable. A no-op 118 (already-clean) file returns unchanged with no log lines.""" 119 if "<<<<<<<" not in text: 120 return text, [] 121 log: list[str] = [] 122 123 def repl(m: re.Match[str]) -> str: 124 ours = m.group("ours").split("\n") if m.group("ours") else [] 125 theirs = m.group("theirs").split("\n") if m.group("theirs") else [] 126 lines, hunk_log = resolve_hunk(ours, theirs) 127 log.extend(hunk_log) 128 return "\n".join(lines) 129 130 merged = CONFLICT_RE.sub(repl, text) 131 if "<<<<<<<" in merged or ">>>>>>>" in merged: 132 raise Unresolvable("leftover conflict marker after substitution — unexpected shape") 133 return merged, log
The whole file's text -> (merged text, log lines), or raise Unresolvable. A no-op (already-clean) file returns unchanged with no log lines.
136def resolve_files(paths: list[Path]) -> list[str]: 137 """All-or-nothing: every file must resolve, or nothing is written. Returns the log lines 138 (prefixed with the file), in argument order. Raises Unresolvable (naming the offending file) 139 on the first file that doesn't reduce to a pure requirements-bound conflict.""" 140 results: dict[Path, tuple[str, list[str]]] = {} 141 for path in paths: 142 text = path.read_text() 143 try: 144 merged, log = merge_text(text) 145 except Unresolvable as exc: 146 raise Unresolvable(f"{path}: {exc}") from exc 147 results[path] = (merged, log) 148 all_log: list[str] = [] 149 for path, (merged, log) in results.items(): 150 path.write_text(merged) 151 all_log.extend(f"{path}: {line}" for line in log) 152 return all_log
All-or-nothing: every file must resolve, or nothing is written. Returns the log lines (prefixed with the file), in argument order. Raises Unresolvable (naming the offending file) on the first file that doesn't reduce to a pure requirements-bound conflict.
158def self_test() -> int: 159 # 1. Adjacent-line conflict: two *different* packages, each bumped by a different commit, on 160 # lines close enough that git's diff3 folds them into one hunk (the reported real-world 161 # case: two dependabot PRs touching adjacent lines of finetune/requirements.txt). Each 162 # package is "changed" on one side and merely present-as-context on the other, so it still 163 # appears on both sides of the hunk with the same package-name set. 164 adjacent = ( 165 "flask>=2.0.0\n" 166 "<<<<<<< HEAD\n" 167 "numpy>=1.24.0\n" 168 "pandas>=1.5.0\n" 169 "=======\n" 170 "numpy>=1.20.0\n" 171 "pandas>=1.6.0\n" 172 ">>>>>>> dependabot/pip/pandas-1.6.0\n" 173 "requests>=2.28.0\n" 174 ) 175 merged, log = merge_text(adjacent) 176 assert "numpy>=1.24.0" in merged, merged # ours' bump of numpy kept (1.24.0 > 1.20.0) 177 assert "pandas>=1.6.0" in merged, merged # theirs' bump of pandas kept (1.6.0 > 1.5.0) 178 assert "<<<<<<<" not in merged and ">>>>>>>" not in merged, merged 179 assert len(log) == 2, log 180 181 # 2. Same package on both sides, different bounds -> take the higher lower bound. 182 same_pkg = "<<<<<<< HEAD\nnumpy>=1.20.0\n=======\nnumpy>=1.26.0\n>>>>>>> theirs\n" 183 merged2, log2 = merge_text(same_pkg) 184 assert merged2.strip() == "numpy>=1.26.0", merged2 185 assert len(log2) == 1 and "1.26.0" in log2[0], log2 186 187 # 3. Upper bound preserved from whichever side has the higher lower bound, and only that side. 188 upper_kept = "<<<<<<< HEAD\nnumpy>=1.28.0,<2.0.0\n=======\nnumpy>=1.26.0\n>>>>>>> theirs\n" 189 merged3, _ = merge_text(upper_kept) 190 assert merged3.strip() == "numpy>=1.28.0,<2.0.0", merged3 191 192 upper_dropped = "<<<<<<< HEAD\nnumpy>=1.24.0,<2.0.0\n=======\nnumpy>=1.26.0\n>>>>>>> theirs\n" 193 merged4, _ = merge_text(upper_dropped) 194 assert merged4.strip() == "numpy>=1.26.0", merged4 # theirs wins, and theirs has no upper 195 196 # 4. A package on only one side -> refuse (a real dependency add/remove, not a pure bump). 197 only_one_side = ( 198 "<<<<<<< HEAD\nnumpy>=1.24.0\nscipy>=1.10.0\n=======\nnumpy>=1.26.0\n>>>>>>> theirs\n" 199 ) 200 try: 201 merge_text(only_one_side) 202 raise AssertionError("expected Unresolvable for a package on only one side") 203 except Unresolvable as exc: 204 assert "scipy" in str(exc), exc 205 206 # 5. Not a requirements-shaped hunk at all -> refuse. 207 not_reqs = "<<<<<<< HEAD\nsome random text\n=======\nother text\n>>>>>>> theirs\n" 208 try: 209 merge_text(not_reqs) 210 raise AssertionError("expected Unresolvable for a non-requirements hunk") 211 except Unresolvable: 212 pass 213 214 # 6. resolve_files: all-or-nothing across two files, via the filesystem. 215 import tempfile 216 217 with tempfile.TemporaryDirectory() as tmp: 218 d = Path(tmp) 219 good = d / "requirements.txt" 220 good.write_text(same_pkg) 221 bad = d / "other-requirements.txt" 222 bad.write_text(not_reqs) 223 try: 224 resolve_files([good, bad]) 225 raise AssertionError("expected Unresolvable when one of two files can't resolve") 226 except Unresolvable: 227 pass 228 # neither file was touched — atomic, all-or-nothing 229 assert good.read_text() == same_pkg 230 assert bad.read_text() == not_reqs 231 232 bad.write_text(adjacent) 233 log_lines = resolve_files([good, bad]) 234 assert good.read_text().strip() == "numpy>=1.26.0" 235 assert "<<<<<<<" not in bad.read_text() 236 assert len(log_lines) == 3, log_lines # 1 from `good`, 2 from `bad` 237 238 print("req_merge self-test: PASSED") 239 return 0
242def main(argv: list[str]) -> int: 243 ap = argparse.ArgumentParser( 244 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 245 ) 246 ap.add_argument("files", nargs="*", type=Path) 247 ap.add_argument("--self-test", action="store_true") 248 args = ap.parse_args(argv) 249 if args.self_test: 250 return self_test() 251 if not args.files: 252 ap.print_help() 253 return 2 254 try: 255 for line in resolve_files(args.files): 256 print(line) 257 except Unresolvable as exc: 258 print(f"req_merge: cannot auto-resolve — {exc}", file=sys.stderr) 259 return 1 260 return 0