uses_merge

uses_merge — auto-resolve one narrow class of git cherry-pick conflict: two GitHub Actions bump commits that touch uses: owner/action@vN lines of the same workflow file on adjacent (or the same) lines, where every line in the conflict hunk, on both sides, is a plain uses: step reference. The sibling of req_merge.py (requirements files): scripts/deps-stack.sh calls this after a cherry-pick fails, on the conflicted .github/workflows/*.yml files, only when every conflicted file is one this module or req_merge.py accepts — anything else stops for a human (see that script's auto_resolve_bumps).

scripts/lib/uses_merge.py <conflicted-file> [<conflicted-file> ...]
scripts/lib/uses_merge.py --self-test

The real-world shape (2026-09-06, just deps-stack on #137 + #138): dependabot bumped actions/checkout@v4 → v7 in every workflow and hadolint/hadolint-action@v3.1.0 → v3.5.0 on the very next line of two of them; git folds the two adjacent one-line changes into one hunk:

<<<<<<< HEAD
      - uses: actions/checkout@v7
      - uses: hadolint/hadolint-action@v3.1.0
=======
      - uses: actions/checkout@v4
      - uses: hadolint/hadolint-action@v3.5.0
>>>>>>> e4f6c8f (Bump hadolint/hadolint-action from 3.1.0 to 3.5.0)

Resolution rule, line by line (both sides must have the same number of uses: lines, naming the same actions in the same order): keep the higher version of each action, compared as a numeric tuple after stripping a leading v — not as a string. Ours' indentation/prefix and any trailing comment are kept. A ref that is not a version (a commit SHA, a branch name) resolves only when both sides agree on it; otherwise this refuses. All-or-nothing per invocation: if any given file's conflict markers don't reduce to this shape, nothing is written to any of the given files and this exits 1 — deps-stack.sh then prints the human-resolve instructions.

  1#!/usr/bin/env python3
  2"""uses_merge — auto-resolve one narrow class of `git cherry-pick` conflict: two GitHub Actions
  3bump commits that touch `uses: owner/action@vN` lines of the same workflow file on adjacent (or
  4the same) lines, where every line in the conflict hunk, on both sides, is a plain `uses:` step
  5reference. The sibling of `req_merge.py` (requirements files): `scripts/deps-stack.sh` calls this
  6after a cherry-pick fails, on the conflicted `.github/workflows/*.yml` files, only when every
  7conflicted file is one this module or `req_merge.py` accepts — anything else stops for a human
  8(see that script's `auto_resolve_bumps`).
  9
 10    scripts/lib/uses_merge.py <conflicted-file> [<conflicted-file> ...]
 11    scripts/lib/uses_merge.py --self-test
 12
 13The real-world shape (2026-09-06, `just deps-stack` on #137 + #138): dependabot bumped
 14`actions/checkout@v4 → v7` in every workflow and `hadolint/hadolint-action@v3.1.0 → v3.5.0` on
 15the very next line of two of them; git folds the two adjacent one-line changes into one hunk:
 16
 17    <<<<<<< HEAD
 18          - uses: actions/checkout@v7
 19          - uses: hadolint/hadolint-action@v3.1.0
 20    =======
 21          - uses: actions/checkout@v4
 22          - uses: hadolint/hadolint-action@v3.5.0
 23    >>>>>>> e4f6c8f (Bump hadolint/hadolint-action from 3.1.0 to 3.5.0)
 24
 25Resolution rule, line by line (both sides must have the same number of `uses:` lines, naming the
 26same actions in the same order): keep the higher version of each action, compared as a numeric
 27tuple after stripping a leading `v` — not as a string. Ours' indentation/prefix and any trailing
 28comment are kept. A ref that is not a version (a commit SHA, a branch name) resolves only when
 29both sides agree on it; otherwise this refuses. All-or-nothing per invocation: if any given
 30file's conflict markers don't reduce to this shape, nothing is written to *any* of the given
 31files and this exits 1 — `deps-stack.sh` then prints the human-resolve instructions.
 32"""
 33
 34import argparse
 35import re
 36import sys
 37from pathlib import Path
 38
 39# One `uses:` step line: optional list dash, `uses:`, `owner/repo[/path]@ref`, optional comment.
 40USES_LINE = re.compile(
 41    r"^(?P<prefix>\s*(?:-\s+)?uses:\s*)(?P<action>[A-Za-z0-9_.\-/]+)@(?P<ref>[^\s#]+)(?P<suffix>\s*(?:#.*)?)$"
 42)
 43VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+)*)$")
 44
 45# Same hunk regex as req_merge.py: an optional diff3 base section is matched and ignored.
 46CONFLICT_RE = re.compile(
 47    r"<<<<<<<[^\n]*\n"
 48    r"(?P<ours>.*?)\n"
 49    r"(?:\|\|\|\|\|\|\|[^\n]*\n.*?\n)?"
 50    r"=======\n"
 51    r"(?P<theirs>.*?)\n"
 52    r">>>>>>>[^\n]*",
 53    re.DOTALL,
 54)
 55
 56
 57class Unresolvable(Exception):
 58    pass
 59
 60
 61def parse_version(ref: str) -> tuple[int, ...] | None:
 62    """ "v3.5.0" -> (3, 5, 0), "v7" -> (7,), a SHA or branch name -> None."""
 63    m = VERSION_RE.match(ref)
 64    return tuple(int(p) for p in m.group(1).split(".")) if m else None
 65
 66
 67def parse_uses(line: str) -> re.Match[str]:
 68    m = USES_LINE.match(line)
 69    if not m:
 70        raise Unresolvable(f"not a plain `uses: owner/action@ref` line: {line!r}")
 71    return m
 72
 73
 74def resolve_hunk(ours_lines: list[str], theirs_lines: list[str]) -> tuple[list[str], list[str]]:
 75    """Merge one hunk's ours/theirs line lists, or raise Unresolvable. Returns the merged lines
 76    (ours' prefix/suffix, the higher ref per action) and one log line per action that differed."""
 77    ours = [parse_uses(line) for line in ours_lines if line.strip()]
 78    theirs = [parse_uses(line) for line in theirs_lines if line.strip()]
 79    if len(ours) != len(theirs):
 80        raise Unresolvable(
 81            f"{len(ours)} `uses:` line(s) on ours vs {len(theirs)} on theirs — a step was added or removed, not bumped"
 82        )
 83    merged: list[str] = []
 84    log: list[str] = []
 85    for o, t in zip(ours, theirs, strict=True):
 86        action = o.group("action")
 87        if action != t.group("action"):
 88            raise Unresolvable(
 89                f"different actions on the same line: {action!r} (ours) vs {t.group('action')!r} (theirs)"
 90            )
 91        o_ref, t_ref = o.group("ref"), t.group("ref")
 92        if o_ref == t_ref:
 93            kept = o_ref
 94        else:
 95            o_v, t_v = parse_version(o_ref), parse_version(t_ref)
 96            if o_v is None or t_v is None:
 97                raise Unresolvable(
 98                    f"{action}: non-version ref(s) differ ({o_ref!r} vs {t_ref!r}) — a SHA or branch pin, not a bump"
 99                )
100            kept, other = (o_ref, t_ref) if o_v >= t_v else (t_ref, o_ref)
101            log.append(f"{action}: kept @{kept} over @{other}")
102        merged.append(f"{o.group('prefix')}{action}@{kept}{o.group('suffix')}")
103    return merged, log
104
105
106def merge_text(text: str) -> tuple[str, list[str]]:
107    """The whole file's text -> (merged text, log lines), or raise Unresolvable."""
108    if "<<<<<<<" not in text:
109        return text, []
110    log: list[str] = []
111
112    def repl(m: re.Match[str]) -> str:
113        ours = m.group("ours").split("\n") if m.group("ours") else []
114        theirs = m.group("theirs").split("\n") if m.group("theirs") else []
115        lines, hunk_log = resolve_hunk(ours, theirs)
116        log.extend(hunk_log)
117        return "\n".join(lines)
118
119    merged = CONFLICT_RE.sub(repl, text)
120    if "<<<<<<<" in merged or ">>>>>>>" in merged:
121        raise Unresolvable("leftover conflict marker after substitution — unexpected shape")
122    return merged, log
123
124
125def resolve_files(paths: list[Path]) -> list[str]:
126    """All-or-nothing: every file must resolve, or nothing is written. Returns the log lines
127    (prefixed with the file), in argument order."""
128    results: dict[Path, tuple[str, list[str]]] = {}
129    for path in paths:
130        try:
131            merged, log = merge_text(path.read_text())
132        except Unresolvable as exc:
133            raise Unresolvable(f"{path}: {exc}") from exc
134        results[path] = (merged, log)
135    all_log: list[str] = []
136    for path, (merged, log) in results.items():
137        path.write_text(merged)
138        all_log.extend(f"{path}: {line}" for line in log)
139    return all_log
140
141
142# --- self-test ----------------------------------------------------------------------------------
143
144
145def self_test() -> int:
146    # 1. The recorded real-world hunk: two adjacent bumps by two commits, each side carrying the
147    #    other's line as unchanged context. Ours' checkout bump and theirs' hadolint bump both win.
148    adjacent = (
149        "    steps:\n"
150        "<<<<<<< HEAD\n"
151        "      - uses: actions/checkout@v7\n"
152        "      - uses: hadolint/hadolint-action@v3.1.0\n"
153        "=======\n"
154        "      - uses: actions/checkout@v4\n"
155        "      - uses: hadolint/hadolint-action@v3.5.0\n"
156        ">>>>>>> e4f6c8f (Bump hadolint/hadolint-action from 3.1.0 to 3.5.0)\n"
157        "        with:\n"
158        "          dockerfile: Dockerfile\n"
159    )
160    merged, log = merge_text(adjacent)
161    assert "      - uses: actions/checkout@v7\n" in merged, merged
162    assert "      - uses: hadolint/hadolint-action@v3.5.0\n" in merged, merged
163    assert "<<<<<<<" not in merged and ">>>>>>>" not in merged, merged
164    assert merged.endswith("        with:\n          dockerfile: Dockerfile\n"), merged
165    assert len(log) == 2, log  # both lines differed: ours' checkout bump, theirs' hadolint bump
166    assert "checkout: kept @v7" in log[0] and "hadolint-action: kept @v3.5.0" in log[1], log
167
168    # 2. Numeric, not string, comparison: v10 beats v9; a trailing comment on ours is kept.
169    numeric = "<<<<<<< HEAD\n  uses: a/b@v9  # pinned\n=======\n  uses: a/b@v10\n>>>>>>> t\n"
170    merged2, log2 = merge_text(numeric)
171    assert merged2.strip() == "uses: a/b@v10  # pinned", merged2
172    assert len(log2) == 1, log2
173
174    # 3. Same ref on both sides is a no-op line, and a step that only one side has is refused.
175    merged3, log3 = merge_text("<<<<<<< HEAD\n- uses: a/b@v1\n=======\n- uses: a/b@v1\n>>>>>>> t\n")
176    assert merged3.strip() == "- uses: a/b@v1" and log3 == [], (merged3, log3)
177    for bad in (
178        "<<<<<<< HEAD\n- uses: a/b@v1\n- uses: c/d@v1\n=======\n- uses: a/b@v2\n>>>>>>> t\n",
179        "<<<<<<< HEAD\n- uses: a/b@v1\n=======\n- uses: c/d@v2\n>>>>>>> t\n",
180        "<<<<<<< HEAD\n- uses: a/b@abc123\n=======\n- uses: a/b@def456\n>>>>>>> t\n",
181        "<<<<<<< HEAD\n  run: echo hi\n=======\n  run: echo bye\n>>>>>>> t\n",
182    ):
183        try:
184            merge_text(bad)
185            raise AssertionError(f"expected Unresolvable for {bad!r}")
186        except Unresolvable:
187            pass
188
189    # 4. resolve_files: all-or-nothing across two files.
190    import tempfile
191
192    with tempfile.TemporaryDirectory() as tmp:
193        d = Path(tmp)
194        good, bad_f = d / "docker.yml", d / "ci.yml"
195        good.write_text(adjacent)
196        bad_f.write_text("<<<<<<< HEAD\n  run: echo hi\n=======\n  run: echo bye\n>>>>>>> t\n")
197        try:
198            resolve_files([good, bad_f])
199            raise AssertionError("expected Unresolvable when one of two files can't resolve")
200        except Unresolvable:
201            pass
202        assert good.read_text() == adjacent  # untouched
203        bad_f.write_text(numeric)
204        lines = resolve_files([good, bad_f])
205        assert "<<<<<<<" not in good.read_text() and "<<<<<<<" not in bad_f.read_text()
206        assert len(lines) == 3, lines  # 2 from docker.yml, 1 from ci.yml
207
208    print("uses_merge self-test: PASSED")
209    return 0
210
211
212def main(argv: list[str]) -> int:
213    ap = argparse.ArgumentParser(
214        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
215    )
216    ap.add_argument("files", nargs="*", type=Path)
217    ap.add_argument("--self-test", action="store_true")
218    args = ap.parse_args(argv)
219    if args.self_test:
220        return self_test()
221    if not args.files:
222        ap.print_help()
223        return 2
224    try:
225        for line in resolve_files(args.files):
226            print(line)
227    except Unresolvable as exc:
228        print(f"uses_merge: cannot auto-resolve — {exc}", file=sys.stderr)
229        return 1
230    return 0
231
232
233if __name__ == "__main__":
234    sys.exit(main(sys.argv[1:]))
USES_LINE = re.compile('^(?P<prefix>\\s*(?:-\\s+)?uses:\\s*)(?P<action>[A-Za-z0-9_.\\-/]+)@(?P<ref>[^\\s#]+)(?P<suffix>\\s*(?:#.*)?)$')
VERSION_RE = re.compile('^v?(\\d+(?:\\.\\d+)*)$')
CONFLICT_RE = re.compile('<<<<<<<[^\\n]*\\n(?P<ours>.*?)\\n(?:\\|\\|\\|\\|\\|\\|\\|[^\\n]*\\n.*?\\n)?=======\\n(?P<theirs>.*?)\\n>>>>>>>[^\\n]*', re.DOTALL)
class Unresolvable(builtins.Exception):
58class Unresolvable(Exception):
59    pass

Common base class for all non-exit exceptions.

def parse_version(ref: str) -> tuple[int, ...] | None:
62def parse_version(ref: str) -> tuple[int, ...] | None:
63    """ "v3.5.0" -> (3, 5, 0), "v7" -> (7,), a SHA or branch name -> None."""
64    m = VERSION_RE.match(ref)
65    return tuple(int(p) for p in m.group(1).split(".")) if m else None

"v3.5.0" -> (3, 5, 0), "v7" -> (7,), a SHA or branch name -> None.

def parse_uses(line: str) -> re.Match[str]:
68def parse_uses(line: str) -> re.Match[str]:
69    m = USES_LINE.match(line)
70    if not m:
71        raise Unresolvable(f"not a plain `uses: owner/action@ref` line: {line!r}")
72    return m
def resolve_hunk( ours_lines: list[str], theirs_lines: list[str]) -> tuple[list[str], list[str]]:
 75def resolve_hunk(ours_lines: list[str], theirs_lines: list[str]) -> tuple[list[str], list[str]]:
 76    """Merge one hunk's ours/theirs line lists, or raise Unresolvable. Returns the merged lines
 77    (ours' prefix/suffix, the higher ref per action) and one log line per action that differed."""
 78    ours = [parse_uses(line) for line in ours_lines if line.strip()]
 79    theirs = [parse_uses(line) for line in theirs_lines if line.strip()]
 80    if len(ours) != len(theirs):
 81        raise Unresolvable(
 82            f"{len(ours)} `uses:` line(s) on ours vs {len(theirs)} on theirs — a step was added or removed, not bumped"
 83        )
 84    merged: list[str] = []
 85    log: list[str] = []
 86    for o, t in zip(ours, theirs, strict=True):
 87        action = o.group("action")
 88        if action != t.group("action"):
 89            raise Unresolvable(
 90                f"different actions on the same line: {action!r} (ours) vs {t.group('action')!r} (theirs)"
 91            )
 92        o_ref, t_ref = o.group("ref"), t.group("ref")
 93        if o_ref == t_ref:
 94            kept = o_ref
 95        else:
 96            o_v, t_v = parse_version(o_ref), parse_version(t_ref)
 97            if o_v is None or t_v is None:
 98                raise Unresolvable(
 99                    f"{action}: non-version ref(s) differ ({o_ref!r} vs {t_ref!r}) — a SHA or branch pin, not a bump"
100                )
101            kept, other = (o_ref, t_ref) if o_v >= t_v else (t_ref, o_ref)
102            log.append(f"{action}: kept @{kept} over @{other}")
103        merged.append(f"{o.group('prefix')}{action}@{kept}{o.group('suffix')}")
104    return merged, log

Merge one hunk's ours/theirs line lists, or raise Unresolvable. Returns the merged lines (ours' prefix/suffix, the higher ref per action) and one log line per action that differed.

def merge_text(text: str) -> tuple[str, list[str]]:
107def merge_text(text: str) -> tuple[str, list[str]]:
108    """The whole file's text -> (merged text, log lines), or raise Unresolvable."""
109    if "<<<<<<<" not in text:
110        return text, []
111    log: list[str] = []
112
113    def repl(m: re.Match[str]) -> str:
114        ours = m.group("ours").split("\n") if m.group("ours") else []
115        theirs = m.group("theirs").split("\n") if m.group("theirs") else []
116        lines, hunk_log = resolve_hunk(ours, theirs)
117        log.extend(hunk_log)
118        return "\n".join(lines)
119
120    merged = CONFLICT_RE.sub(repl, text)
121    if "<<<<<<<" in merged or ">>>>>>>" in merged:
122        raise Unresolvable("leftover conflict marker after substitution — unexpected shape")
123    return merged, log

The whole file's text -> (merged text, log lines), or raise Unresolvable.

def resolve_files(paths: list[pathlib.Path]) -> list[str]:
126def resolve_files(paths: list[Path]) -> list[str]:
127    """All-or-nothing: every file must resolve, or nothing is written. Returns the log lines
128    (prefixed with the file), in argument order."""
129    results: dict[Path, tuple[str, list[str]]] = {}
130    for path in paths:
131        try:
132            merged, log = merge_text(path.read_text())
133        except Unresolvable as exc:
134            raise Unresolvable(f"{path}: {exc}") from exc
135        results[path] = (merged, log)
136    all_log: list[str] = []
137    for path, (merged, log) in results.items():
138        path.write_text(merged)
139        all_log.extend(f"{path}: {line}" for line in log)
140    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.

def self_test() -> int:
146def self_test() -> int:
147    # 1. The recorded real-world hunk: two adjacent bumps by two commits, each side carrying the
148    #    other's line as unchanged context. Ours' checkout bump and theirs' hadolint bump both win.
149    adjacent = (
150        "    steps:\n"
151        "<<<<<<< HEAD\n"
152        "      - uses: actions/checkout@v7\n"
153        "      - uses: hadolint/hadolint-action@v3.1.0\n"
154        "=======\n"
155        "      - uses: actions/checkout@v4\n"
156        "      - uses: hadolint/hadolint-action@v3.5.0\n"
157        ">>>>>>> e4f6c8f (Bump hadolint/hadolint-action from 3.1.0 to 3.5.0)\n"
158        "        with:\n"
159        "          dockerfile: Dockerfile\n"
160    )
161    merged, log = merge_text(adjacent)
162    assert "      - uses: actions/checkout@v7\n" in merged, merged
163    assert "      - uses: hadolint/hadolint-action@v3.5.0\n" in merged, merged
164    assert "<<<<<<<" not in merged and ">>>>>>>" not in merged, merged
165    assert merged.endswith("        with:\n          dockerfile: Dockerfile\n"), merged
166    assert len(log) == 2, log  # both lines differed: ours' checkout bump, theirs' hadolint bump
167    assert "checkout: kept @v7" in log[0] and "hadolint-action: kept @v3.5.0" in log[1], log
168
169    # 2. Numeric, not string, comparison: v10 beats v9; a trailing comment on ours is kept.
170    numeric = "<<<<<<< HEAD\n  uses: a/b@v9  # pinned\n=======\n  uses: a/b@v10\n>>>>>>> t\n"
171    merged2, log2 = merge_text(numeric)
172    assert merged2.strip() == "uses: a/b@v10  # pinned", merged2
173    assert len(log2) == 1, log2
174
175    # 3. Same ref on both sides is a no-op line, and a step that only one side has is refused.
176    merged3, log3 = merge_text("<<<<<<< HEAD\n- uses: a/b@v1\n=======\n- uses: a/b@v1\n>>>>>>> t\n")
177    assert merged3.strip() == "- uses: a/b@v1" and log3 == [], (merged3, log3)
178    for bad in (
179        "<<<<<<< HEAD\n- uses: a/b@v1\n- uses: c/d@v1\n=======\n- uses: a/b@v2\n>>>>>>> t\n",
180        "<<<<<<< HEAD\n- uses: a/b@v1\n=======\n- uses: c/d@v2\n>>>>>>> t\n",
181        "<<<<<<< HEAD\n- uses: a/b@abc123\n=======\n- uses: a/b@def456\n>>>>>>> t\n",
182        "<<<<<<< HEAD\n  run: echo hi\n=======\n  run: echo bye\n>>>>>>> t\n",
183    ):
184        try:
185            merge_text(bad)
186            raise AssertionError(f"expected Unresolvable for {bad!r}")
187        except Unresolvable:
188            pass
189
190    # 4. resolve_files: all-or-nothing across two files.
191    import tempfile
192
193    with tempfile.TemporaryDirectory() as tmp:
194        d = Path(tmp)
195        good, bad_f = d / "docker.yml", d / "ci.yml"
196        good.write_text(adjacent)
197        bad_f.write_text("<<<<<<< HEAD\n  run: echo hi\n=======\n  run: echo bye\n>>>>>>> t\n")
198        try:
199            resolve_files([good, bad_f])
200            raise AssertionError("expected Unresolvable when one of two files can't resolve")
201        except Unresolvable:
202            pass
203        assert good.read_text() == adjacent  # untouched
204        bad_f.write_text(numeric)
205        lines = resolve_files([good, bad_f])
206        assert "<<<<<<<" not in good.read_text() and "<<<<<<<" not in bad_f.read_text()
207        assert len(lines) == 3, lines  # 2 from docker.yml, 1 from ci.yml
208
209    print("uses_merge self-test: PASSED")
210    return 0
def main(argv: list[str]) -> int:
213def main(argv: list[str]) -> int:
214    ap = argparse.ArgumentParser(
215        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
216    )
217    ap.add_argument("files", nargs="*", type=Path)
218    ap.add_argument("--self-test", action="store_true")
219    args = ap.parse_args(argv)
220    if args.self_test:
221        return self_test()
222    if not args.files:
223        ap.print_help()
224        return 2
225    try:
226        for line in resolve_files(args.files):
227            print(line)
228    except Unresolvable as exc:
229        print(f"uses_merge: cannot auto-resolve — {exc}", file=sys.stderr)
230        return 1
231    return 0