0

The problem of foxtrot merges explained in details in this question. In a nutshell: When working on the same branch (remote and local), the simple git merge command hides the remote merged commits under 2nd parent, and replaces the first-parents history of the remote (in the regular case - "origin"). And we consider it to be bad.

The linked-above question asks for a way to detect this situation, and reject the push. My question is how to automatically fix it.

I don't really care about the neat-tidiness of the full tree history, but I want that the first-parents tree will be perfect. And it is important to not disturb the other users, so the problem should solve itself automatically.

The goal: When the git origin hook detects foxtrot commit push, it will add another merge-commit, above the old-HEAD, and the new-HEAD, putting the old-HEAD as first-parent.

What is the safest way to achieve it?

Yaakov Shoham
  • 10,182
  • 7
  • 37
  • 45
  • Can't you simply disallow fast forwards on origin/master? Always require a merge commit? – Lasse V. Karlsen Dec 21 '21 at 14:50
  • @LasseV.Karlsen I didn't understand exactly how to do your suggestion, or how it'll help. (a) The new-HEAD *is* merge commit. (b) I don't want to block the users. I want that the users will push as they want, and that the server will fix it. Pull-Request processes (like in TFS) actually do kind-of what I want (they create new-merge-commit, with the correct first-parent). My problem with this is that we don't have PR (and it is an extra step for the user), and also - we don't work on TFS. – Yaakov Shoham Dec 21 '21 at 16:45

1 Answers1

0

In the inspiration of the answers to the mentioned above question and to this question (merges in bare repo), I came out with the solution below.

This script should be put in the post-receive hook file. As requested, the script creates new-merge-commit that doesn't hide the old-HEAD.

The script also:

  1. Collects all the commit-msgs of the first-parent line of the new-HEAD, and sets it as the new-merge-commit msg.
  2. Fakes the author and committer of the new-merge-commit, to be as the author of the new-HEAD (while taking care cases of " or ' in the name).

Commands explanation:

  1. The git read-tree command stages the exact content of the new-HEAD commit. The new-merge-commit should be with the same exact content.
  2. The git log command collects the needed commit messages.
  3. The git ... commit-tree command creates the new-merge-commit with the author name/email, the staged data, the 2 parents, and the combined commit msg.
  4. The git update-ref command updates the relevant ref (in this case - master) to point to the new-merge-commit.

If there are problems with this script, it would be very appreciated if you'll spot them. :)

The script:

#!/bin/bash

while read oldrev newrev refname
do
if [ "$refname" = "refs/heads/master" ]; then
    echo "Fix Foxtrot-Merges Hook..."
    MATCH=`git log --first-parent --pretty='%H %P' $oldrev..$newrev | grep $oldrev | awk '{ print \$2 }'`
    if [ "$oldrev" = "$MATCH" ]; then
        echo "...All is OK"
    else
        echo "...Fixing"
        
        authorName=`git log --first-parent --pretty='%an' $newrev^..$newrev`
        authorEmail=`git log --first-parent --pretty='%ae' $newrev^..$newrev`
        
        authorName=${authorName//"/\\"}
        authorEmail=${authorEmail//"/\\"}
        
        git read-tree -i --reset $newrev
        
        git log --first-parent --pretty='%B' $oldrev..$newrev > COMMIT_EDITMSG
        
        newCommitHash=$(git -c user.name="$authorName" -c user.email="$authorEmail" commit-tree $(git write-tree) -p $oldrev -p $newrev < COMMIT_EDITMSG)
        
        git update-ref $refname $newCommitHash
    fi
fi
done
Yaakov Shoham
  • 10,182
  • 7
  • 37
  • 45
  • 1
    This will mostly-work for the most common case, which might be good enough. However, the real problem here is that it *can't be fixed* because you cannot change existing commits. You can simply *not accept it* (which Git works well enough with), or you can pseduo-accept-it-and-rewrite-it as you are doing here. But when you do the pseudo-accept, the *sending* Git thinks you did a normal full accept and updates their remote-tracking name incorrectly. This may surprise others. It's better to get people to stop doing it in the first place, if you can. – torek Dec 21 '21 at 19:52
  • 1
    Meanwhile, as a small refinement, consider using the existing merge commit's tree (`git rev-parse ${rev}^{tree}`) rather than reading a tree into the index and then writing out the index. Also, you can use `git log -1` or `git log --no-walk` to extract author name and email and body text a bit easier. Unfortunately `git log` is technically porcelain rather than plumbing so it suffers from user-config-syndrome, but there's no plumbing equivalent to use. – torek Dec 21 '21 at 19:55
  • Last: watch out for users who make several foxtrot merges, then push all of them with one `git push`. That's the case you're missing here: you only rewrite the tip commit. – torek Dec 21 '21 at 19:57
  • @torek Thank you very much for your comments! Regarding to the first one, I see your points. In my case, it is good enough. In case of some crysis that should be managed very specificaly - we can temporarly disable this. About the second: Good ideas! I'll try it. – Yaakov Shoham Dec 22 '21 at 23:04
  • @torek About the last point, I think you are incorrect, or I didn't understand you. The old-HEAD (= the one that is the most recent `origin/master` that any other programmer and the build server knows about, until current user's push) will always be one first-parnet step from the auto-created commit (that is now the HEAD). No matter how many times the user `git pull` and it doesn't matter from which commits did the user pull; in the bottom line - the old-HEAD commit won't be hidden. – Yaakov Shoham Dec 22 '21 at 23:09
  • What I mean is: I can pull one new commit from you and hide it behind one commit from me (that's one foxtrot merge). Then (note that I do not `git push` yet) I can make one new commit, pull one *more* commit from you, and hide your *second* commit as my *second* merge's second parent. Repeat for as many commit pairs (or sets) as we like: I keep hiding your commit(s) as my second-parent of each of my merges. Then I run *one* `git push origin master`, sending N merges to the shared repository. – torek Dec 22 '21 at 23:30
  • @torek Oh. In the case you've described you pulled the commits from *my personal repo* and not from the *the shared repo ('origin')*. It's not a problem in my eyes: my commits have never been in the origin, so they are not part of the official history, and they aren't protected. If you will do so by pulling from the *origin*, the hook will work. – Yaakov Shoham Dec 23 '21 at 07:15
  • It doesn't matter where I *get* the commits. What matters is the *graph I make*. I can get them from `origin` too. – torek Dec 23 '21 at 07:53
  • @torek It will work well. Lets take the case of "hiding" 2 commits: A and B. You pulled A and hide it in your repo. Now you pull again and got B and hide it too. **But**, you pulled B from origin, and that means that *B already has A as it's first parent*, because someone previously pushed commits to origin and B was created according to the hook policy - "don't hide old-HEAD". So, when you'll push C, and the origin-hook will create C2 with B as its 1st parent, A will be still the 1st parent of B too - so no hiding will occur. – Yaakov Shoham Dec 23 '21 at 10:55
  • No, when I pull and get A, I hide A behind a new merge commit M1. M1's first parent is my commit C1. Later, I pull and get B, and make it the second parent of my new merge commit M2 (with first parent C2; C2 has M1 as its parent). When I push now, I have *two* merges that you want to rewrite. – torek Dec 23 '21 at 12:12
  • @torek Oh, I understand. Thank you very much for your explanations. I think I didn't clarify our needs well. M1/C1 are local commits that never have been public. No formal builds used M1/C1 (and no installer with build-num was created for it and sent away to QA), and no other random programmer saw them. M1/C1 are only yours, and I don't care if they'll be shown in the first-parent line of origin/master. After you pushed M2 to origin/master, and after the hook will run, all the public past HEADs of origin/master are still be there in the 1st-parent-line of a newly created HEAD. So it's fine. :) – Yaakov Shoham Dec 23 '21 at 12:30