Finally, "git-commit--onto-parent.sh"[*1*] shows an initial script version for you to examine, test out and hopefully comment on :) Especially interesting part might be index-only three-way file merge, through usage of "git-merge-one-file--cached" script. Of course, this still only works for some trivial resolutions, where in case of more complex ones, involving unresolved conflicts, we back-out and fail. Still, it should be more powerful than `git-apply`. Consider this proof of concept and work in progress, an idea where I`d like feedback on everything you come up with or find interesting, even parameter name possibly used instead of "--onto-parent" (and its short version), or approach in general. For example, it might make sense to separate commit creation (on current HEAD`s parent) and its actual re-merging into integration test branch, where "--remerge" (or something) parameter would be used on top of "--onto-parent" to trigger both, if/when desired. Another direction to think in might be introducing more general "--onto" parameter, too (or instead), without "parent" restriction, allowing to record a commit on top of any arbitrary commit (other than HEAD). This could even be defaulted to "git commit <commit-ish>" (no option needed), where current "git commit" behaviour would then just be a special case of omitted <commit-ish> defaulting to HEAD, aligning well with other Git commands sharing the same behaviour. Alas, rewind to present... Please do note that I`m still relatively new to Git, and pretty new to both Linux and scripting in general (on Windows as well), and the whole concept of open-source software contributing, even, so please bare with me (or at least don`t get upset too much, lol), and do feel free to share your thoughts and remarks, even the trivial or harsh ones -- I`m grateful to learn and expand my knowledge, hopefully producing something useful in return :) Heck, might be I`m totally off-track here as well. p.s. For some context - nowadays I mostly work in Delphi, and occasionally in C#, though through last 20 years I`ve been involved with C, Pascal, Basic, but also PHP, JavaScript, and whatnot - even good old assembly from time to time, when needed :) Regards, Buga [*1*] "git-commit--onto-parent.sh", probably too heavily commented in the first place, but as I`m new to everything here I kind of feel the plain words might unfortunately describe my intention a bit better than my code, for now at least. --- 8< --- #!/bin/sh # # Copyright (c) 2017 Igor Djordjevic i=$# while test $i != 0 do # # Parameter parsing might be uninteresting here, as the whole # script is currently just a wrapper around `git commit`, for a # functionality that conceptually belongs there directly. # case "$1" in --onto-parent=*) onto_parent="${1#*=}" shift && i=$(expr $i - 1) ;; --onto-parent) shift && i=$(expr $i - 1) onto_parent="$1" shift && i=$(expr $i - 1) ;; -a|--a|--al|--all) all=t # # For now, `git commit` "--all" option is special-cased in # terms of being stripped out of the original command line # (to be passed to `git commit`) and processed manually, as # once commit is to be made, due to states of index and # working tree, "--all" is most probably NOT what the user # wants nor expects ;) # shift && i=$(expr $i - 1) ;; *) # parameters to pass down to `git commit` set -- "$@" "$1" shift && i=$(expr $i - 1) ;; esac done main () { # # Store current HEAD (ref or commit) and verify that # --onto-parent is valid and amongst its parents. # head="$(git symbolic-ref --short --quiet HEAD)" || head="$(git rev-parse HEAD^0)" && verify_onto_parent "$head" "$onto_parent" || exit 1 # # As both HEAD and "--onto-parent" could be refs, where underlying # commits could change, store original commits for later parents # processing, getting updated parent list for new/updated # merge commit. # head_commit="$(git rev-parse "$head"^0)" && onto_parent_commit="$(git rev-parse "$onto_parent"^0)" || exit 1 # # Custom processing of stripped "--all" parameter - if we were to # just pass it to `git commit`, "--all" would most probably yield # an unexpected result in the eyes of the user, as it would include # _all changes from all the other merge commit parents as well_, # not just the changes we may actually wanted to "push down" # (commit) onto specified parent (what would `git diff` show), # due to state of index and working tree at the time of commit. # if test -n "$all" then git add --update fi # # Abort if no cached changes, nothing to be committed. # git diff --cached --quiet if test $? -eq 0 then printf >&2 '%s\n' "error: no changes added to commit" exit 1 fi # # Backup current index to be restored (and committed) in the end. # merge_index="$(git write-tree)" || exit 1 # # Reset index to destination parent (without touching working # tree), and try applying cached changes. # # In case changes do not apply cleanly onto desired parent, abort. # apply_changes "$head_commit" "$onto_parent_commit" "$merge_index" "$onto_parent" || exit 1 # # Move HEAD to specified parent (without touching working tree) # to prepare to record a commit there, and make the commit, # passing parameters through to `git commit`. # # In case of error, or when `git commit` didn`t actually make # the commit, like when --dry-run parameter is provided (for # example), abort, as there is nothing to do - no new commit, no # need to produce the "updated" merge commit, either. # # Note that we don`t abort right away, as restoring original # index and HEAD position is needed all the same, so we # potentially abort only once that is done, a bit further below. # # [ This is something that could be thought of a bit more, # might be forbidding passing through of some `git commit` # parameters in the first place, like --dry-run...? ] # move_head "$onto_parent" && git commit "$@" if test $? -ne 0 || { new_parent_commit="$(git rev-parse HEAD^0)" test "$new_parent_commit" = "$onto_parent_commit" } then no_commit=t fi # # Remove entry from HEAD reflog, not to pollute it with # uninteresting in-between steps we take, leaking implementation # details to end user. # # We do left it inside corresponding branch reflog where commit # is made (if $onto_parent was a branch), though, as that`s where # it still matters. # git reflog delete HEAD@{0} # # Restore original index state and move HEAD to original position, # (still not touching working tree), aborting if previously # signalled. # git read-tree --reset "$merge_index" && move_head "$head" && test -z "$no_commit" || exit 1 # # Drop original HEAD merge commit to have it replaced by # upcoming "updated" merge commit. # # This step is needed for eventually getting an expected merge # message out of "git fmt-merge-msg", as it seems HEAD dependent # as well, beside being input format picky already...? # #git update-ref --create-reflog -m "reset: moving to HEAD^" HEAD HEAD^ || exit 1 reflog_ref="$(git symbolic-ref --short --quiet HEAD)" git update-ref HEAD HEAD^ || exit 1 # # Remove both HEAD and underlying reference reflog entries this # time, as here we really want to mask previous step completely, # being taken just to satisfy "git fmt-merge-msg" expectations. # if test -n "$reflog_ref" then git reflog delete "$reflog_ref"@{0} fi git reflog delete HEAD@{0} # # Prepare "updated" merge commit message and parent list. # if test -n "$(git rev-parse --verify --quiet $head_commit^2^{commit})" then merge_parents="$(get_merge_parents "$head_commit" "$onto_parent" "$onto_parent_commit" "$new_parent_commit")" && merge_message="$(get_merge_message "$merge_parents")" || exit 1 else # # As we`re actually selling the option as "--onto-parent" and # not "--onto-MERGE-parent", we might as well properly support # a special case where HEAD commit is not a merge. # # Existing HEAD commit will come after commit to be made, # basically being kind of rebased onto new commit (but still # not touching working tree), where we can then also reuse # original HEAD commit authorship, and message, too (instead # of building a merge one). # merge_parents="$new_parent_commit" && merge_message="$(git show -s --format=%B "$head_commit")" && reuse_authorship $head_commit || exit 1 fi merge_parent_commits="$(get_merge_parent_commits "$merge_parents")" && # # Do the actual commit, updating HEAD accordingly. # merge_commit="$(printf '%s\n' "$merge_message" | git commit-tree "$merge_index" $merge_parent_commits)" && git update-ref --create-reflog -m "$merge_message" HEAD "$merge_commit" || exit 1 } verify_onto_parent () { # # $1 starting point head (ref or commit) # $2 parent of $1 to commit onto (ref or commit) # local head="$1" local onto_parent="$2" if test -z "$onto_parent" then printf >&2 '%s\n' "error: no parent provided" printf >&2 '%s\n' "(use \"--onto-parent <commit-ish>\"" return 1; fi if test -z "$(git rev-parse --verify --quiet "$onto_parent"^{commit})" then printf >&2 '%s\n' "error: '$onto_parent' not valid commit object" return 1 fi local onto_parent_commit="$(git rev-parse "$onto_parent"^0)" for parent_commit in $(git rev-parse $head^@) do if test "$parent_commit" = "$onto_parent_commit" then return 0 fi done printf >&2 '%s\n' "error: '$onto_parent' not parent of '$head'" return 1 } apply_changes () { # # $1 original/starting point HEAD commit # $2 parent commit of $1 to apply changes to # $3 index with changes on top of $1 to apply/merge onto $2 # $4 original parameter value of $2 (ref or commit), used for # prettier message only # local head_commit="$1" local onto_parent_commit="$2" local merge_index="$3" local onto_parent="$4" git read-tree --reset $onto_parent_commit && # # Attempt simple patching first - take differences between # $head_commit and $merge_index and try applying to current index # (previously reset to $onto_parent_commit). # git diff-tree --binary --patch --find-renames --find-copies $head_commit $merge_index | git apply --cached 2>/dev/null && return 0 printf '%s\n' "Unable to apply cleanly onto '$onto_parent', trying simple merge" # # A bit more aggressive approach - try merging with resolving # trivial conflicts on tree level only (involving file as a whole, # no conflicts inside file itself). # # Note that we take $head_commit as merge-base, producing such # three-way merge result that basically all changes between # $onto_parent_commit and $head_commit are reversed, as they`re # also included inside $merge_index, where only differences # between $head_commit and $merge_index are applied (in a # three-way merge manner) to $onto_parent_commit, being exactly # what we want here. # git read-tree -i -m --aggressive $head_commit $onto_parent_commit $merge_index || exit 1 git write-tree >/dev/null 2>&1 && return 0 printf '%s\n' "Simple merge did not work, trying automatic merge" # # Final attempt - try merging with resolving trivial conflicts on # file level, too (conflicts inside file itself). # # Notice usage of "git-merge-one-file--cached" script here, being # a slightly tweaked version of original "git-merge-one-file", # not touching working tree but stuffing trivial three-way # file merge resolution back into index directly. # # If still left with conflicts that need to be resolved manually, # abort... and go home, you`re drunk. # if ! git merge-index -o git-merge-one-file--cached -a then # abort, cleanup git read-tree --reset $merge_index exit 1 fi } move_head () { # # $1 destination ref or commit # # Move HEAD to $1 without touching the working tree. # # Kind of "soft checkout", where original "git checkout" touches # the working tree, and "git reset --soft" does not move HEAD, # both undesired here. # local destination="$1" local destination_commit="$(git rev-parse --verify --quiet $destination^0)" || { printf >&2 '%s\n' "fatal: invalid reference: $destination" return 1 } #local reflog_message="$(get_checkout_reflog_message $destination)" local destination_ref="$(git rev-parse --symbolic-full-name $destination)" case "$destination_ref" in refs/heads/*) # can`t use "update-ref --no-deref" as it writes commit only, # instead of ref, essentially detaching HEAD to that commit #git symbolic-ref -m "$reflog_message" HEAD "$destination_ref" git symbolic-ref HEAD "$destination_ref" ;; refs/tags/*|\ refs/remotes/*|\ "") # can`t use "symbolic-ref" as it refuses to write commit only, # expecting a valid ref instead (value inside "refs/") #git update-ref --create-reflog -m "$reflog_message" --no-deref HEAD "$destination_commit" git update-ref --no-deref HEAD "$destination_commit" # mask this step as end-user uninteresting implementation detail git reflog delete HEAD@{0} ;; *) printf >&2 '%s\n' "fatal: invalid reference: $destination_ref" return 1 ;; esac return 0 } get_merge_parents () { # # $1 original/starting point HEAD commit # $2 parent of $1 to commit onto (ref or commit) # $3 original commit of $2 (if $2 is ref, otherwise equals $2) # $4 new commit (onto $2, to be new merge parent) # # Walk original merge commit parents to find the one we`re posting # onto (or amending, even), and update/replace it accordingly with # new commit, becoming a new parent of upcoming new/updated merge # commit. # # Where possible, prefer taking ref over commit, making for a # prettier merge commit message. # local head_commit="$1" local onto_parent="$2" local onto_parent_old_commit="$3" local new_parent_commit="$4" local merge_parents= local onto_parent_new_commit="$(git rev-parse $onto_parent^0)" for parent_commit in $(git rev-parse $head_commit^@) do local merge_parent= if test "$parent_commit" = "$onto_parent_old_commit" then if test "$onto_parent_new_commit" = "$new_parent_commit" then # $onto_parent is a branch (updateable ref) merge_parent="$onto_parent" else merge_parent="$new_parent_commit" fi else parent_ref="$(git for-each-ref --points-at $parent_commit --count=1 --format="%(refname)")" if test -n "$parent_ref" then merge_parent="$parent_ref" else merge_parent="$parent_commit" fi fi # echo to flatten whitespace merge_parents="$(echo $merge_parents $merge_parent)" done if test -n "$merge_parents" then printf '%s\n' "$merge_parents" return 0 else return 1 fi } get_merge_message () { # # $@ merge_parents # # Provide to-be merge commit message using # existing `git fmt-merge-msg` machinery. # local merge_heads="$(get_merge_heads $@)" && local merge_message="$(printf "$merge_heads" | git fmt-merge-msg)" if test -n "$merge_message" then printf '%s\n' "$merge_message" return 0 else return 1 fi } get_merge_heads () { # # $@ merge_parents # # Provide input for `git fmt-merge-msg` to get # nicely formatted merge commit message. # # Final result loosely mimics FETCH_HEAD file layout. # local merge_heads= local merge_head= # # Skip the first parent, as that is the original merge # destination, where we`re only interested in parents to be # merged into it. # shift for merge_parent in $@ do local merge_parent_ref="$(git rev-parse --symbolic-full-name $merge_parent)" local merge_parent_commit="$(git rev-parse $merge_parent)" case "$merge_parent_ref" in refs/heads/*) merge_head="$merge_parent_commit\t\tbranch '${merge_parent_ref#refs/heads/}' of ." ;; refs/tags/*) merge_head="$merge_parent_commit\t\ttag '${merge_parent_ref#refs/tags/}' of ." ;; refs/remotes/*) merge_head="$merge_parent_commit\t\tremote-tracking branch '${merge_parent_ref#refs/remotes/}' of ." ;; *) merge_head="$merge_parent_commit\t\t'$(git rev-parse --short $merge_parent_commit)' of ." ;; esac merge_heads="$merge_heads$merge_head\n" done if test -n "$merge_heads" then # '\n' already appended printf '%s' "$merge_heads" return 0 else return 1 fi } reuse_authorship () { # # $1 commit to reuse authorship from # local commit="$1" GIT_AUTHOR_NAME="$(git show -s --format=%an $commit)" GIT_AUTHOR_EMAIL="$(git show -s --format=%ae $commit)" GIT_AUTHOR_DATE="$(git show -s --format=%at $commit)" export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE } get_merge_parent_commits () { # # $@ merge_parents (might contain ref) # # Provide to-be merge commit parent parameters # in format suitable for `git commit-tree`. # local merge_parent_commits= for merge_parent in $@ do merge_parent_commits="$(printf '%s\n' "$merge_parent_commits -p $(git rev-parse $merge_parent^0)")" done if test -n "$merge_parent_commits" then printf '%s\n' "$merge_parent_commits" return 0 else return 1 fi } get_checkout_reflog_message () { # # $1 destination ref or commit # local destination="$1" local source= source="$(git symbolic-ref --short --quiet HEAD)" || source="$(git rev-parse HEAD^0)" printf '%s' "checkout: moving from $source to $destination" } main "$@"