From 8986c258e46159ab69c990a178a8ebc8fabe3e32 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 13:42:54 +0000 Subject: [PATCH] feat: add --absorbed to record the old parent as merged Asserts that already carries 's changes (squash merge, rebase merge, cherry-picks): 's tip is recorded as an extra parent of the merge, so the result descends from it and git and GitHub treat as merged instead of dropped. On a conflict the extra parent rides along in MERGE_HEAD, so the resolver's plain git commit records it too. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PHUVU3Ek3U9j3LrdjPnyAy --- README.md | 23 +++++++ src/git_merge_onto/__init__.py | 62 +++++++++++++----- tests/test_cli.py | 6 +- tests/test_merge.py | 116 +++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 42f7005..84cd7ba 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,32 @@ any merge: edit the files, `git add -A`, `git commit`. | flag | meaning | |------|---------| | `-m, --message ` | commit message for a clean merge | +| `--absorbed` | record `` as an extra parent (see below) | | `--quiet` | do not echo the executed git commands | | `--version` | print the version | +### `--absorbed`: mark the old parent as merged + +By default the re-parent *drops* ``: the result does not descend from its +tip, so git and GitHub keep treating `` as unmerged. That is right when +you pull a branch out from under a live parent, and wrong when `` already +carries ``'s changes without its commits -- after a squash merge, a rebase +merge, or a cherry-pick. `--absorbed` asserts the latter: ``'s tip is +recorded as an extra parent of the merge, so the result descends from it and +git and GitHub treat `` as merged. + +In the example above, with `git merge-onto --absorbed develop feature` the +re-homed `followup` contains `feature`, so its PR stays mergeable (and CI keeps +running) even while it is still based on `feature`, and GitHub shows `feature` +as merged into it. If `feature`'s tip is already an ancestor the extra parent +is redundant and git drops it, so the flag is safe to pass whenever the premise +holds. + +This cannot be a default: whether ``'s changes are already in `` is a +fact about how `` landed, not something visible in the commit graph. When +they are not (moving a branch off a live parent), the extra parent would +falsely mark `` as merged into your branch. + ## How it works `git merge-onto ` reduces to a single plumbing call: diff --git a/src/git_merge_onto/__init__.py b/src/git_merge_onto/__init__.py index 488bdc7..f68bbc2 100644 --- a/src/git_merge_onto/__init__.py +++ b/src/git_merge_onto/__init__.py @@ -12,6 +12,11 @@ conflicting. Too high -- when the new parent transitively contains HEAD's own commit (a reorder) -- and a plain merge fast-forwards, silently dropping HEAD's change. Forcing the base to merge-base(HEAD, ) is correct in both. + +With --absorbed, the merge also records 's tip as a parent: an assertion +that already carries 's changes even though is not its +ancestor (squash merge, rebase merge, cherry-picks), so git and GitHub treat + as merged instead of dropped. """ from __future__ import annotations @@ -134,25 +139,31 @@ def blocking_operation() -> str | None: return None -def setup_merge_markers(theirs: str, message: str, head_tip: str) -> None: +def setup_merge_markers(merge_parents: list[str], message: str, head_tip: str) -> None: """Write the in-progress-merge state `git commit` reads to finalize a merge: - parents come from HEAD + MERGE_HEAD, the message from MERGE_MSG.""" + parents come from HEAD + MERGE_HEAD (one per line), the message from MERGE_MSG.""" gd = git_dir() - (gd / "MERGE_HEAD").write_text(theirs + "\n") + (gd / "MERGE_HEAD").write_text("".join(p + "\n" for p in merge_parents)) (gd / "MERGE_MODE").write_text("") (gd / "MERGE_MSG").write_text(message + "\n") (gd / "ORIG_HEAD").write_text(head_tip + "\n") -def merge_with_base(base: str, theirs: str, message: str) -> bool: +def merge_with_base(base: str, theirs: str, message: str, extra_parent: str | None = None) -> bool: """Merge `theirs` into HEAD as if `base` were the merge base -- a `git merge` with a caller-chosen base, the one thing git porcelain cannot do. Clean -> commits with parents [HEAD, theirs] and returns True. Conflict -> leaves the merge in progress (MERGE_HEAD set, conflict markers in the worktree) and returns False, so the caller (or a human) resolves and `git commit`s normally. + + `extra_parent` is recorded as an additional parent, on the clean path and the + conflict path alike (it rides along in MERGE_HEAD, so the resolver's plain + `git commit` picks it up). `git commit` drops any parent that is an ancestor + of another, so a redundant extra parent is harmless. """ head_tip = git("rev-parse", "HEAD") + merge_parents = [theirs] if extra_parent is None else [theirs, extra_parent] # 3-way merge into index+worktree with the merge base forced to `base`. rc = git_rc("merge-recursive", base, "--", head_tip, theirs) # merge-recursive returns 0 = clean, 1 = content conflict, >1 = it refused to run @@ -160,17 +171,20 @@ def merge_with_base(base: str, theirs: str, message: str) -> bool: # when there is a real merge to finalize; on a refusal, raise so we never fabricate # a merge commit or clobber an existing MERGE_HEAD. if rc == 0: - # A re-parent normally changes the tree; if it doesn't AND `theirs` is already - # an ancestor, the merge commit would add nothing (no content, no new ancestor), - # so skip it. (Don't skip merely because `theirs` is an ancestor: re-parenting - # onto a trunk that is already an ancestor still must drop the old parent's content.) - if git("write-tree") == git("rev-parse", "HEAD^{tree}") and git_rc("merge-base", "--is-ancestor", theirs, head_tip) == 0: + # A re-parent normally changes the tree; if it doesn't AND every parent to + # record is already an ancestor, the merge commit would add nothing (no + # content, no new ancestor), so skip it. (Don't skip merely because `theirs` + # is an ancestor: re-parenting onto a trunk that is already an ancestor still + # must drop the old parent's content.) + if git("write-tree") == git("rev-parse", "HEAD^{tree}") and all( + git_rc("merge-base", "--is-ancestor", p, head_tip) == 0 for p in merge_parents + ): return True - setup_merge_markers(theirs, message, head_tip) + setup_merge_markers(merge_parents, message, head_tip) git("commit", "--no-edit") return True if rc == 1: - setup_merge_markers(theirs, message, head_tip) + setup_merge_markers(merge_parents, message, head_tip) return False raise CommandError( [GIT, "merge-recursive", base, "--", head_tip, theirs], @@ -185,10 +199,15 @@ def _resolve_commit(ref: str) -> str | None: return rev_parse(ref) or rev_parse(f"origin/{ref}") -def merge_onto(new: str, old: str, message: str | None = None) -> bool: +def merge_onto(new: str, old: str, message: str | None = None, absorbed: bool = False) -> bool: """Re-parent HEAD onto `new`, dropping `old`. Returns True on a clean merge (committed), False on a conflict (left in progress to resolve and commit). - Raises UserError on a precondition failure (dirty tree, bad ref, no ancestor).""" + Raises UserError on a precondition failure (dirty tree, bad ref, no ancestor). + + `absorbed` asserts that `new` already carries `old`'s changes without its + commits (squash merge, rebase merge, cherry-picks): `old`'s tip is then + recorded as an extra parent of the merge, so the result descends from it + and git and GitHub treat `old` as merged instead of dropped.""" # merge-recursive writes straight into the index/worktree, so refuse to run during # another git operation or on a dirty tree rather than corrupt either. op = blocking_operation() @@ -208,11 +227,11 @@ def merge_onto(new: str, old: str, message: str | None = None) -> bool: if not base: raise UserError(f"no common ancestor between HEAD and old parent {old!r}") msg = message or f"Merge {new} into HEAD, dropping {old}" - return merge_with_base(base, new_sha, msg) + return merge_with_base(base, new_sha, msg, extra_parent=old_sha if absorbed else None) -def cmd_merge_onto(new: str, old: str, message: str | None) -> int: - if merge_onto(new, old, message): +def cmd_merge_onto(new: str, old: str, message: str | None, absorbed: bool = False) -> int: + if merge_onto(new, old, message, absorbed=absorbed): print(bold(f"git merge-onto: merged {new} into HEAD, dropping {old}."), file=sys.stderr) return 0 print( @@ -235,6 +254,15 @@ def build_parser() -> argparse.ArgumentParser: ), ) p.add_argument("-m", "--message", help="commit message for a clean merge") + p.add_argument( + "--absorbed", + action="store_true", + help=( + "assert that already carries 's changes (squash merge, rebase " + "merge, cherry-picks): record as an extra parent of the merge, so " + "git and GitHub treat as merged instead of dropped" + ), + ) p.add_argument("--quiet", action="store_true", help="do not echo executed git commands") p.add_argument("--version", action="version", version=f"git-merge-onto {__version__}") p.add_argument("new", help="the new parent to merge into HEAD") @@ -248,7 +276,7 @@ def main(argv: list[str] | None = None) -> int: if args.quiet: VERBOSE = False try: - return cmd_merge_onto(args.new, args.old, args.message) + return cmd_merge_onto(args.new, args.old, args.message, args.absorbed) except UserError as e: print(red(f"git merge-onto: error: {e}"), file=sys.stderr) return 2 diff --git a/tests/test_cli.py b/tests/test_cli.py index 2917478..9949e75 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,13 +8,13 @@ def test_parser_basic(): args = gmo.build_parser().parse_args(["new", "old"]) assert args.new == "new" and args.old == "old" - assert args.message is None and args.quiet is False + assert args.message is None and args.quiet is False and args.absorbed is False def test_parser_flags(): - args = gmo.build_parser().parse_args(["-m", "msg", "--quiet", "develop", "feature"]) + args = gmo.build_parser().parse_args(["-m", "msg", "--quiet", "--absorbed", "develop", "feature"]) assert args.new == "develop" and args.old == "feature" - assert args.message == "msg" and args.quiet is True + assert args.message == "msg" and args.quiet is True and args.absorbed is True def test_parser_requires_two_positionals(): diff --git a/tests/test_merge.py b/tests/test_merge.py index 943a209..22bd385 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -116,6 +116,122 @@ def test_squash_regime_clean_where_plain_merge_conflicts(repo): assert len(parents(repo)) == 2 and out(repo, "rev-parse", "develop") in parents(repo) +def test_absorbed_records_old_tip_as_parent(repo): + # The squash regime with the old parent advanced past the fork point: followup forked + # from feature at v1, feature advanced to v2, then was squash-merged into develop. + # Without --absorbed the re-parented followup does not descend from feature's tip, so + # a PR still based on feature reads as unmerged/conflicting; --absorbed records the + # tip as a parent and restores the ancestry. + commit_file(repo, "base.txt", "base\n", "main") + sh(repo, "switch", "-q", "-c", "feature") + commit_file(repo, "shared.txt", "v1\n", "feature v1") + sh(repo, "switch", "-q", "-c", "followup") + fu_old = commit_file(repo, "followup.txt", "F\n", "followup") + sh(repo, "switch", "-q", "feature") + feature_tip = commit_file(repo, "shared.txt", "v2\n", "feature v2") + sh(repo, "switch", "-q", "-c", "develop", "main") + commit_file(repo, "shared.txt", "v2\n", "squash of feature") + sh(repo, "switch", "-q", "followup") + + assert gmo.main(["--quiet", "--absorbed", "develop", "feature"]) == 0 + + assert (repo / "shared.txt").read_text() == "v2\n" + assert out(repo, "diff", "--name-only", "develop", "HEAD") == "followup.txt" + ps = parents(repo) + assert len(ps) == 3 + assert fu_old in ps and out(repo, "rev-parse", "develop") in ps and feature_tip in ps + assert is_ancestor(repo, "feature", "HEAD") + + +def test_absorbed_redundant_when_old_is_ancestor(repo): + # When followup already contains feature's tip, the extra parent is redundant and + # `git commit` drops it: same two-parent commit as without the flag. + commit_file(repo, "base.txt", "base\n", "main") + sh(repo, "switch", "-q", "-c", "feature") + commit_file(repo, "shared.txt", "v1\n", "feature") + sh(repo, "switch", "-q", "-c", "followup") + fu_old = commit_file(repo, "followup.txt", "F\n", "followup") + sh(repo, "switch", "-q", "-c", "develop", "main") + commit_file(repo, "shared.txt", "v1-squashed\n", "squash of feature") + sh(repo, "switch", "-q", "followup") + + assert gmo.merge_onto("develop", "feature", absorbed=True) is True + + ps = parents(repo) + assert sorted(ps) == sorted([fu_old, out(repo, "rev-parse", "develop")]) + + +def test_absorbed_conflict_resolves_to_three_parents(repo): + # On a conflict the extra parent rides along in MERGE_HEAD, so the resolver's + # plain `git add`/`git commit` records it without knowing about the flag. + commit_file(repo, "f.txt", "1\n2\n3\n", "main") + sh(repo, "switch", "-q", "-c", "feature") + commit_file(repo, "f.txt", "1\nA\n3\n", "feature v1") + sh(repo, "switch", "-q", "-c", "followup") + fu_old = commit_file(repo, "f.txt", "1\nB\n3\n", "followup") + sh(repo, "switch", "-q", "feature") + feature_tip = commit_file(repo, "f.txt", "1\nA2\n3\n", "feature v2") + sh(repo, "switch", "-q", "-c", "develop", "main") + commit_file(repo, "f.txt", "1\nA2-squashed\n3\n", "squash of feature") + sh(repo, "switch", "-q", "followup") + + assert gmo.merge_onto("develop", "feature", absorbed=True) is False + assert gmo.in_progress_merge() + assert "<<<<<<<" in (repo / "f.txt").read_text() + + (repo / "f.txt").write_text("1\nB\n3\n") + sh(repo, "add", "f.txt") + sh(repo, "commit", "--no-edit") + + ps = parents(repo) + assert len(ps) == 3 + assert fu_old in ps and out(repo, "rev-parse", "develop") in ps and feature_tip in ps + assert is_ancestor(repo, "feature", "HEAD") + + +def test_absorbed_bookkeeping_commit_when_tree_unchanged(repo): + # followup already has develop's content (a plain `git merge develop`, resolved by + # hand), so the re-parent changes nothing and develop is already an ancestor -- but + # feature's tip is not. The no-op skip must not fire: a tree-identical commit is + # created purely to record feature as merged. + commit_file(repo, "base.txt", "base\n", "main") + sh(repo, "switch", "-q", "-c", "feature") + commit_file(repo, "shared.txt", "v1\n", "feature v1") + sh(repo, "switch", "-q", "-c", "followup") + commit_file(repo, "followup.txt", "F\n", "followup") + sh(repo, "switch", "-q", "feature") + feature_tip = commit_file(repo, "shared.txt", "v2\n", "feature v2") + sh(repo, "switch", "-q", "-c", "develop", "main") + commit_file(repo, "shared.txt", "v2\n", "squash of feature") + sh(repo, "switch", "-q", "followup") + sh(repo, "checkout", "-q", "develop", "--", "shared.txt") + sh(repo, "commit", "-q", "-m", "hand-resolved merge of develop", "--", "shared.txt") + sh(repo, "merge", "-q", "--no-edit", "-s", "ours", "develop") # develop now an ancestor + fu_merged = out(repo, "rev-parse", "HEAD") + old_tree = out(repo, "rev-parse", "HEAD^{tree}") + + assert gmo.merge_onto("develop", "feature", absorbed=True) is True + + assert out(repo, "rev-parse", "HEAD") != fu_merged # a commit was made + assert out(repo, "rev-parse", "HEAD^{tree}") == old_tree # with the same tree + ps = parents(repo) + assert sorted(ps) == sorted([fu_merged, feature_tip]) # develop dropped as redundant + assert is_ancestor(repo, "feature", "HEAD") + + +def test_absorbed_noop_skip_when_old_also_ancestor(repo): + # Everything the flag would record is already an ancestor: the skip still fires and + # no commit is created. + commit_file(repo, "f.txt", "x\n", "main") + sh(repo, "switch", "-q", "-c", "a") + commit_file(repo, "a.txt", "A\n", "a") + sh(repo, "switch", "-q", "-c", "b") + b_old = commit_file(repo, "b.txt", "B\n", "b") + + assert gmo.merge_onto("a", "a", absorbed=True) is True + assert out(repo, "rev-parse", "HEAD") == b_old + + def test_conflict_left_in_progress_then_resolved(repo): # Move b onto `other` (a sibling off main), dropping a. b and other touch the same # line, so the re-parent conflicts; other is not an ancestor of b, so the resolved