44# dependencies = []
55# ///
66#
7- # Vendored from https://github.com/scortexio/git-merge-onto (v0.1 .0): a single
7+ # Vendored from https://github.com/scortexio/git-merge-onto (v0.2 .0): a single
88# zero-dependency file so the action re-parents a branch without a network
99# download. Do not edit here -- change it upstream, publish, and re-sync.
1010"""git merge-onto: re-parent HEAD onto <new>, dropping <old>.
@@ -21,6 +21,11 @@ its commit (a squash-merge) -- and a plain merge re-applies <old>, often
2121conflicting. Too high -- when the new parent transitively contains HEAD's own
2222commit (a reorder) -- and a plain merge fast-forwards, silently dropping HEAD's
2323change. Forcing the base to merge-base(HEAD, <old>) is correct in both.
24+
25+ With --absorbed, the merge also records <old>'s tip as a parent: an assertion
26+ that <new> already carries <old>'s changes even though <old> is not its
27+ ancestor (squash merge, rebase merge, cherry-picks), so git and GitHub treat
28+ <old> as merged instead of dropped.
2429"""
2530
2631from __future__ import annotations
@@ -32,7 +37,7 @@ import subprocess
3237import sys
3338from pathlib import Path
3439
35- __version__ = "0.1 .0" # vendored snapshot; see the header above
40+ __version__ = "0.2 .0" # vendored snapshot; see the header above
3641
3742# Point at a specific git binary (used by the test suite; "git" otherwise).
3843GIT = os .environ .get ("GIT_MERGE_ONTO_GIT" , "git" )
@@ -113,11 +118,8 @@ def git_dir() -> Path:
113118 return Path (git ("rev-parse" , "--absolute-git-dir" ))
114119
115120
116- def worktree_dirty (include_untracked : bool = True ) -> bool :
117- args = ["status" , "--porcelain" ]
118- if not include_untracked :
119- args .append ("--untracked-files=no" )
120- return bool (git (* args ))
121+ def worktree_dirty () -> bool :
122+ return bool (git ("status" , "--porcelain" ))
121123
122124
123125def in_progress_merge () -> bool :
@@ -141,43 +143,52 @@ def blocking_operation() -> str | None:
141143 return None
142144
143145
144- def setup_merge_markers (theirs : str , message : str , head_tip : str ) -> None :
146+ def setup_merge_markers (merge_parents : list [ str ] , message : str , head_tip : str ) -> None :
145147 """Write the in-progress-merge state `git commit` reads to finalize a merge:
146- parents come from HEAD + MERGE_HEAD, the message from MERGE_MSG."""
148+ parents come from HEAD + MERGE_HEAD (one per line) , the message from MERGE_MSG."""
147149 gd = git_dir ()
148- (gd / "MERGE_HEAD" ).write_text (theirs + "\n " )
150+ (gd / "MERGE_HEAD" ).write_text ("" . join ( p + "\n " for p in merge_parents ) )
149151 (gd / "MERGE_MODE" ).write_text ("" )
150152 (gd / "MERGE_MSG" ).write_text (message + "\n " )
151153 (gd / "ORIG_HEAD" ).write_text (head_tip + "\n " )
152154
153155
154- def merge_with_base (base : str , theirs : str , message : str ) -> bool :
156+ def merge_with_base (base : str , theirs : str , message : str , extra_parent : str | None = None ) -> bool :
155157 """Merge `theirs` into HEAD as if `base` were the merge base -- a `git merge`
156158 with a caller-chosen base, the one thing git porcelain cannot do.
157159
158160 Clean -> commits with parents [HEAD, theirs] and returns True. Conflict ->
159161 leaves the merge in progress (MERGE_HEAD set, conflict markers in the worktree)
160162 and returns False, so the caller (or a human) resolves and `git commit`s normally.
163+
164+ `extra_parent` is recorded as an additional parent, on the clean path and the
165+ conflict path alike (it rides along in MERGE_HEAD, so the resolver's plain
166+ `git commit` picks it up). `git commit` drops any parent that is an ancestor
167+ of another, so a redundant extra parent is harmless.
161168 """
162169 head_tip = git ("rev-parse" , "HEAD" )
170+ merge_parents = [theirs ] if extra_parent is None else [theirs , extra_parent ]
163171 # 3-way merge into index+worktree with the merge base forced to `base`.
164172 rc = git_rc ("merge-recursive" , base , "--" , head_tip , theirs )
165173 # merge-recursive returns 0 = clean, 1 = content conflict, >1 = it refused to run
166174 # at all (dirty index/worktree, bad arg). Only set the in-progress-merge markers
167175 # when there is a real merge to finalize; on a refusal, raise so we never fabricate
168176 # a merge commit or clobber an existing MERGE_HEAD.
169177 if rc == 0 :
170- # A re-parent normally changes the tree; if it doesn't AND `theirs` is already
171- # an ancestor, the merge commit would add nothing (no content, no new ancestor),
172- # so skip it. (Don't skip merely because `theirs` is an ancestor: re-parenting
173- # onto a trunk that is already an ancestor still must drop the old parent's content.)
174- if git ("write-tree" ) == git ("rev-parse" , "HEAD^{tree}" ) and git_rc ("merge-base" , "--is-ancestor" , theirs , head_tip ) == 0 :
178+ # A re-parent normally changes the tree; if it doesn't AND every parent to
179+ # record is already an ancestor, the merge commit would add nothing (no
180+ # content, no new ancestor), so skip it. (Don't skip merely because `theirs`
181+ # is an ancestor: re-parenting onto a trunk that is already an ancestor still
182+ # must drop the old parent's content.)
183+ if git ("write-tree" ) == git ("rev-parse" , "HEAD^{tree}" ) and all (
184+ git_rc ("merge-base" , "--is-ancestor" , p , head_tip ) == 0 for p in merge_parents
185+ ):
175186 return True
176- setup_merge_markers (theirs , message , head_tip )
187+ setup_merge_markers (merge_parents , message , head_tip )
177188 git ("commit" , "--no-edit" )
178189 return True
179190 if rc == 1 :
180- setup_merge_markers (theirs , message , head_tip )
191+ setup_merge_markers (merge_parents , message , head_tip )
181192 return False
182193 raise CommandError (
183194 [GIT , "merge-recursive" , base , "--" , head_tip , theirs ],
@@ -192,10 +203,15 @@ def _resolve_commit(ref: str) -> str | None:
192203 return rev_parse (ref ) or rev_parse (f"origin/{ ref } " )
193204
194205
195- def merge_onto (new : str , old : str , message : str | None = None ) -> bool :
206+ def merge_onto (new : str , old : str , message : str | None = None , absorbed : bool = False ) -> bool :
196207 """Re-parent HEAD onto `new`, dropping `old`. Returns True on a clean merge
197208 (committed), False on a conflict (left in progress to resolve and commit).
198- Raises UserError on a precondition failure (dirty tree, bad ref, no ancestor)."""
209+ Raises UserError on a precondition failure (dirty tree, bad ref, no ancestor).
210+
211+ `absorbed` asserts that `new` already carries `old`'s changes without its
212+ commits (squash merge, rebase merge, cherry-picks): `old`'s tip is then
213+ recorded as an extra parent of the merge, so the result descends from it
214+ and git and GitHub treat `old` as merged instead of dropped."""
199215 # merge-recursive writes straight into the index/worktree, so refuse to run during
200216 # another git operation or on a dirty tree rather than corrupt either.
201217 op = blocking_operation ()
@@ -215,11 +231,11 @@ def merge_onto(new: str, old: str, message: str | None = None) -> bool:
215231 if not base :
216232 raise UserError (f"no common ancestor between HEAD and old parent { old !r} " )
217233 msg = message or f"Merge { new } into HEAD, dropping { old } "
218- return merge_with_base (base , new_sha , msg )
234+ return merge_with_base (base , new_sha , msg , extra_parent = old_sha if absorbed else None )
219235
220236
221- def cmd_merge_onto (new : str , old : str , message : str | None ) -> int :
222- if merge_onto (new , old , message ):
237+ def cmd_merge_onto (new : str , old : str , message : str | None , absorbed : bool = False ) -> int :
238+ if merge_onto (new , old , message , absorbed = absorbed ):
223239 print (bold (f"git merge-onto: merged { new } into HEAD, dropping { old } ." ), file = sys .stderr )
224240 return 0
225241 print (
@@ -242,6 +258,15 @@ def build_parser() -> argparse.ArgumentParser:
242258 ),
243259 )
244260 p .add_argument ("-m" , "--message" , help = "commit message for a clean merge" )
261+ p .add_argument (
262+ "--absorbed" ,
263+ action = "store_true" ,
264+ help = (
265+ "assert that <new> already carries <old>'s changes (squash merge, rebase "
266+ "merge, cherry-picks): record <old> as an extra parent of the merge, so "
267+ "git and GitHub treat <old> as merged instead of dropped"
268+ ),
269+ )
245270 p .add_argument ("--quiet" , action = "store_true" , help = "do not echo executed git commands" )
246271 p .add_argument ("--version" , action = "version" , version = f"git-merge-onto { __version__ } " )
247272 p .add_argument ("new" , help = "the new parent to merge into HEAD" )
@@ -255,7 +280,7 @@ def main(argv: list[str] | None = None) -> int:
255280 if args .quiet :
256281 VERBOSE = False
257282 try :
258- return cmd_merge_onto (args .new , args .old , args .message )
283+ return cmd_merge_onto (args .new , args .old , args .message , args . absorbed )
259284 except UserError as e :
260285 print (red (f"git merge-onto: error: { e } " ), file = sys .stderr )
261286 return 2
0 commit comments