How can I format the code in a multi-branch project?
Asked Answered
G

2

11

So we have this hundreds of thousands of lines of code git repository and since I joined the project 2 years ago, the formatting bugs me. And it not only bugs me but as devs randomly "fix" the fomratting, merges result in headache when the code-formatting was applied on one side only. Now reformat code is a two minutes task but results in merge conflict hell, too. I recently merged master to a long-living feature branch and tried:

  • format code in master, merge to feature branch: 3-way merge tool meld gives me exactly the mess I mentioned above. Doesn't detect function boundaries. Really no fun to merge.
  • format code in master, format code in feature branch, merge master: Now I still get 30 files with conflicts that are much easier to sort out

Now I wonder if it's worth merging, as there are another 15 branches that will all need the exact same code reviews and as manual merging is error-prone I wonder if there is some way of doing this without getting these merge conflicts.

Gradient answered 30/10, 2017 at 19:45 Comment(0)
S
10

Edit, Jun 2022

I'm just boosting the signal from Rufus' comment below:

https://github.com/emilio/clang-format-merge contains code that provides a merge driver, rather than clean and smudge filters. It looks likely to be useful though, especially for repositories that have never had standard formatting enforced.

Recipe with assumptions

(note: I have not tested any of this)

We'll assume the reformatter is in ~/Downloads/android-studio/bin/format.sh and [note: apparently this is a bad assumption!] that it reads stdin and writes stdout, and works on one file at a time. (It's possible, but very difficult, to make this work with something that needs more than one file at a time. You cannot use this recipe for this case, though. Git's basic filtering mechanism requires that each filter simply read stdin and write stdout. By default Git assumes the filter works, even if it exits with a failure status.)

Choose where to run the filter as well; here I've set it up as the "clean" filter only.

In ~/.gitconfig or .git/config, add the definition for the filter:

[filter "my-xyz-language-formatter"]
    clean = ~/Downloads/android-studio/bin/format.sh
    smudge = cat

(this assumes that running cat runs a filter that writes, to its stdout, its unchanged input; this is true on any Unix-like system).

Then, create a .gitattributes file if needed. It will apply to the directory you create it in, and all sub-directories, unless overridden in those sub-directories, so place it in the highest sensible location, usually the root of the repository, but sometimes underneath a source/ or src/ or whatever directory. Add line(s) to direct file(s) matching some pattern(s) through your formatter. We'll assume here that all files named *.xyz should be formatted:

*.xyz   filter=my-xyz-language-formatter

This filter will now apply to all extractions and insertions of *.xyz files. The gitattributes documentation talks about these being applied at check-out and check-in time, but that's not quite precisely correct. Instead, a clean filter is applied whenever Git copies from work-tree to index (essentially, git add—well before git commit unless you use git commit -a or similar flags). A smudge filter is applied whenever Git copies from index to work-tree (essentially, git checkout, but also some additional cases, such as git reset --hard).

Note that spinning up one filter for each file can be quite slow. There's a "long running filter process" protocol you can use if you have a lot of control over the filter, which can speed this up (especially on Windows). That's beyond the scope of this answer, though.

Running git merge normally does not use the filters (it works on the copies that are already in the index, which is outside the filtering step). However, adding -X renormalize to a standard merge will make git merge do the "virtual check-in and check-out" described below, so that it will apply the filters. This happens for all three commits involved in the merge (and in both directions—clean and smudge—so it's roughly 6x slower than for just one commit).

Description (see below)

Git itself is only partially helpful here.

Fundamentally, the problem is that Git is stupid and line-oriented: it runs git diff from the merge base commit to each tip commit. If one or both of these git diffs sees a lot of formatting changes, it considers those significant and worthy of applying to the base. It has no semantic knowledge of the input code.

(Since you can take over the entire merge process, you could write a smarter merge that does use semantic analysis. This is pretty difficult, though. The only system I know of that does this, or something approaching this, is Ira Baxter's commercial software, and I've never actually used that; I just understand the theory behind it.)

There is a solution that does not depend on making Git smarter. If you have a semantic analyzer that outputs consistently formatted code, regardless of the input form, you can feed all three versions—B for base, L for left or local or --ours, and R for right or remote or other or --theirs—into this formatter:

reformat < B > B.formatted
reformat < L > L.formatted
reformat < R > R.formatted

Now you can have Git merge all three formatted versions, rather than merging the original possibly-not-yet-formatted (but maybe formatted) versions.

The result of this merge will, of course, be re-formatted. But presumably this is what you'd like anyway.

The way to achieve this with Git's built-in tools is to use what it calls smudge and clean filters. A smudge filter is applied to files as they are extracted from the repository into the work-tree. A clean filter is applied to files whenever they go from the work-tree into the repository.

In this case, the smudge filter can be "do nothing to the data", preserving exactly what was committed. The clean filter can be the reformatter. Or, if you prefer, the smudge filter can be the reformatter, and the clean filter can be the reformatter again, or a no-op filter. Once you have this in place—this is something you set up in .gitattributes, by defining a filter for particular files by path names, and the filter-driver in .git/config or your main (user or system wide) .gitconfig.

Once you have all that set up, you can run git merge -X renormalize. Git will extract the B, L, and R versions as usual, but then run them through a "virtual check-out and check-in" step, making three temporary commits,1 B.formatted and so on. It then does the merge using the three temporary commits, rather than from the original three commits.

The hard part is finding a reformatter that does just what you want / need. Some modern systems have them, e.g., gofmt or clang-format. If there's one that does what you need, it just becomes a matter of plugging all this together—and getting buy-in from the rest of your group, that this reformatting is a good idea.


1Technically it just makes tree objects; there's no need for actual commits.

Sawn answered 30/10, 2017 at 20:28 Comment(11)
I didn't know about the smudge and clean filters! Thanks for that. The process you propose is basically what I already did manually when merges went ugly. Knowing how to automate this is awesome. In the hope of a better solution, I'll not award this answer just yet. Getting an all-formatted state would really help.Gradient
I think for a complete answer, the config should be fleshed out a bit more. So assuming my format script was ~/Downloads/android-studio/bin/format.sh, where would I put that exactly?Gradient
Sure - it's a bit complex, I'll assume that path and also some other items and try to outline all the assumptions.Sawn
ok, Android Studio's format.sh is not only not streaming but also weird. astyle is streaming when used with the $ astyle < file syntax. uncrustify comes with zero styling. Is there no standard? Am I missing something obvious? I'm aiming for the most standard formatting of mainly Java and Kotlin.Gradient
I don't use either Java or Kotlin and don't know of any formatters for them. For others, the problem is not so much a standard, as which standard. :-) xkcd.com/927Sawn
love xkcd and I precisely don't want to invent a standard and don't care about a standard but would really prefer to use something pre-exising. Anything. Unfortunately astyle styles differently than AS which results in annoying things like "git stash" resulting in a dirty directory. Also I think you have it reverse above. smudge should be format.sh (for other users I guess we can shorten the path to just format.sh).Gradient
You can choose which way to go (smudge on the way out of repository to format, or clean on the way in, or both): it's all a matter of what you want to happen to which files when.Sawn
Not exactly smooth my experience. Just went through with mergetool and the merges looked nice but it skipped the java files that are actually affected by the filter. Seams it applies the filter after adding conflict markers (<<<<<<< HEAD ...) as those are indented and not recognized as such anymore. Running git mergetool tells me that there are no remaining conflicts. Learned something today but not happy yet.Gradient
Let us continue this discussion in chat.Gradient
Here's a merge tool that basically implements the above for clang-format: github.com/emilio/clang-format-mergeTrotter
@Rufus: this is a merge-time trick rather than a commit-time trick. The clean/smudge method is to let individual coders use their own preferred style while keeping the repository commits in some standard style, which is a different approach. However, the merge driver is likely to prove handy, so I've mentioned it at the top of the answer.Sawn
G
1

While torek probably got me on a good track, it did not help me to get the reformatting done across branches. The problem was that the filter applied after git had added these

<<<< HEAD
bla foo 123
====
bla 123
>>>> otherBranch

blocks, so the filter would indent the conflict markers ... which is not good.

While this probably has some solution, I went with a custom merge tool:

#!/bin/bash

BASE=$1
LOCAL=$2
REMOTE=$3
MERGED=$4

if echo "$BASE" | grep -q "\.java"; then
    echo "Normalizing java file";
    astyle $BASE
    astyle $LOCAL
    astyle $REMOTE
    astyle $MERGED
fi


meld "$LOCAL" "$BASE" "$REMOTE" --output "$MERGED"

configured in .gitconfig as:

[merge]
    tool = customMergeTool
[mergetool "customMergeTool"]
    cmd = /path/to/customMergeTool.sh \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"

With my approach, git would still detect conflicts that when handled with my script are without merge conflicts in 40 of my 100 cases, so torek's approach could probably speed things up there but I ran into serious issues merging the other 40 files, so I gave it up for now.

Gradient answered 30/10, 2017 at 19:45 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.