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:]))
REQ_LINE = re.compile('^\\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*$')
CONFLICT_RE = re.compile('<<<<<<<[^\\n]*\\n(?P<ours>.*?)\\n(?:\\|\\|\\|\\|\\|\\|\\|[^\\n]*\\n.*?\\n)?=======\\n(?P<theirs>.*?)\\n>>>>>>>[^\\n]*', re.DOTALL)
class Unresolvable(builtins.Exception):
48class Unresolvable(Exception):
49    pass

Common base class for all non-exit exceptions.

def parse_version(v: str) -> tuple[int, ...]:
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.

def parse_requirement(line: str) -> tuple[str, str, str] | None:
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.

def resolve_hunk( ours_lines: list[str], theirs_lines: list[str]) -> tuple[list[str], list[str]]:
 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.

def merge_text(text: str) -> tuple[str, list[str]]:
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.

def resolve_files(paths: list[pathlib.Path]) -> list[str]:
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.

def self_test() -> int:
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
def main(argv: list[str]) -> int:
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