Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,32 @@ any merge: edit the files, `git add -A`, `git commit`.
| flag | meaning |
|------|---------|
| `-m, --message <msg>` | commit message for a clean merge |
| `--absorbed` | record `<old>` 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* `<old>`: the result does not descend from its
tip, so git and GitHub keep treating `<old>` as unmerged. That is right when
you pull a branch out from under a live parent, and wrong when `<new>` already
carries `<old>`'s changes without its commits -- after a squash merge, a rebase
merge, or a cherry-pick. `--absorbed` asserts the latter: `<old>`'s tip is
recorded as an extra parent of the merge, so the result descends from it and
git and GitHub treat `<old>` 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 `<old>`'s changes are already in `<new>` is a
fact about how `<old>` 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 `<old>` as merged into your branch.

## How it works

`git merge-onto <new> <old>` reduces to a single plumbing call:
Expand Down
62 changes: 45 additions & 17 deletions src/git_merge_onto/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, <old>) is correct in both.

With --absorbed, the merge also records <old>'s tip as a parent: an assertion
that <new> already carries <old>'s changes even though <old> is not its
ancestor (squash merge, rebase merge, cherry-picks), so git and GitHub treat
<old> as merged instead of dropped.
"""

from __future__ import annotations
Expand Down Expand Up @@ -134,43 +139,52 @@ 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
# at all (dirty index/worktree, bad arg). Only set the in-progress-merge markers
# 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],
Expand All @@ -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()
Expand All @@ -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(
Expand All @@ -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 <new> already carries <old>'s changes (squash merge, rebase "
"merge, cherry-picks): record <old> as an extra parent of the merge, so "
"git and GitHub treat <old> 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")
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
116 changes: 116 additions & 0 deletions tests/test_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down