gitgit-merge-conflict

Can I write a specific rule about how git should handle a specific commonly occurring merge conflict in a specific file?


I have an Obsidian vault under version control, and one of the plugins synchronises with an external source, updating a timestamp in the config file.

I switch between two separate user accounts for security reasons, so when I pull the repo, I almost always have conflicts in that file.

{
  "dateHighlightedFormat": "yyyy-MM-dd HH:mm:ss",
  ...
  "filter": "ALL",
<<<<<<< HEAD
  "syncAt": "2024-07-21T22:24:25",
=======
  "syncAt": "2024-07-22T09:35:14",
>>>>>>> 6630c5d461bcb5c4950e2694a67c60aee57acc7d
  ...
}

In this case, I always want to use the latest timestamp. There is no guarantee which file is the most current, if it's the local or the remote.

Is there a way I can configure this for git?

I imagine that it should be possible to run a shell script on a specific hook. If the script exits with a zero exit code, it was able to handle the merge successfully, and git would create the merge. On a non-zero, git would fallback to normal merge behaviour, requiring user interaction for conflict resolution.

But that's just my idea of one possible approach to a solution. Maybe there are other better solutions?

p.s. I seem to recall that NPM was able to setup something specifically for package-lock.json. So unless I am mistaken about this, it should be technically possible.


Solution

  • You can write a custom merge driver.

    Suppose we have a merge driver /usr/bin/latest.py. Remember to make it executable if not on Windows.

    In gitconfig:

    [merge."latest"]
            name = "choose the latest timestamp"
            driver = /usr/bin/latest.py %O %A %B %L %P
    

    In .gitattributes:

    package-lock.json    merge=latest
    

    Here is an crude implementation of latest.py in Python 3 (3.11.2 in my machine). If the shebang #!/usr/bin/env python does not work, try #!/usr/bin/env python3. You can write the merge driver in any programming language you like. It's a bit crude, as it's not really aware of JSON, and relies on the fact that ISO date/times sort alphabetically in this scenario.

    It first creates two temporary files for the two branches being merged, and replace the syncAt line with the latest timestamp in both. After that it proceeds to call git merge-file -p passing these instead of the original. So for that particular line, git merge-file will see the same change, which will not conflict, but anything else will fallback to default git merge behaviour, making sure other conflicts will fail, requiring manual resolution.

    #!/usr/bin/env python3
    import sys
    import os
    import subprocess
    import tempfile
    import logging
    import traceback
    
    ancestor = sys.argv[1]
    current = sys.argv[2]
    other = sys.argv[3]
    KEYWORD = '"syncAt":'
    
    def merge_file(current, ancestor, other):
        cmd = 'git merge-file -p "%s" "%s" "%s"' % (current, ancestor, other)
        status, output = subprocess.getstatusoutput(cmd)
        return status, output
    
    def get_syncat_line(p):
        syncat = ''
        with open(p) as f:
            for line in f:
                if line.strip().startswith(KEYWORD):
                    syncat = line
                    break
        return syncat
    
    def replace_syncat_line(p, replacement, output):
        with open(p) as f:
            for line in f:
                if line.strip().startswith(KEYWORD):
                    output.write(replacement)
                    output.write("\n")
                else:
                    output.write(line)
            output.write("\n")
    
    def write_output_exit(output, status):
        with open(current, 'w') as f:
            f.write(output)
        sys.exit(status)
    
    current_syncat = get_syncat_line(current)
    other_syncat = get_syncat_line(other)
    ancestor_syncat = get_syncat_line(ancestor)
    
    def create_tmp_file():
        return tempfile.NamedTemporaryFile('w', delete_on_close=False) 
    
    if (current_syncat.strip() == other_syncat.strip()):
        status, output = merge_file(current, ancestor, other)
        write_output_exit(output, status)
    else:
        try:
            if current_syncat.strip() > other_syncat.strip():
                latest_syncat = current_syncat.rstrip()
            else:
                latest_syncat = other_syncat.rstrip()
    
            with create_tmp_file() as tmp_current, \
                create_tmp_file() as tmp_other:
    
                replace_syncat_line(current, latest_syncat, tmp_current)
                replace_syncat_line(other, latest_syncat, tmp_other)
                tmp_current.close()
                tmp_other.close()
                status, output = merge_file(
                    tmp_current.name, ancestor, tmp_other.name)
    
                write_output_exit(output, status)
        except Exception as e:
            logging.error(traceback.format_exc())
            status, output = merge_file(current, ancestor, other)
            write_output_exit(output, 1)