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:]))
Common base class for all non-exit exceptions.
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.
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.
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.
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.
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
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