Skip to content

Instantly share code, notes, and snippets.

@dqh-au
Created November 10, 2022 03:14
Show Gist options
  • Save dqh-au/7426c2268d11a6c07671e3d742e09fff to your computer and use it in GitHub Desktop.
Save dqh-au/7426c2268d11a6c07671e3d742e09fff to your computer and use it in GitHub Desktop.
Better macOS git difftool + FileMerge integration
#!/bin/bash
#
# Copyright 2022 David Hogan <david.q.hogan@gmail.com>
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
# OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
# OF THE POSSIBILITY OF SUCH DAMAGE.
#
set -o nounset
set -o errexit
#
# FEATURES:
#
# - Present a tree view of (only) new, changed, or deleted files
# - Files edited within FileMerge save to working copy
# - If a file is edited, saved, then closed in FileMerge, then
# reopening in the same FileMerge session will display the latest
# changes
# - No unnecessary file copying
#
# INSTALL:
#
# 1. Place a copy of this script somewhere. If you don't put it in
# the $PATH, then you'll need to put the full path to it in the
# 'cmd' entry in the config below.
#
# 2. Edit .gitconfig in your home directory to include the following:
#
# [difftool]
# prompt = false
# [difftool "opendiff-git"]
# cmd = opendiff-git.sh \"$MERGED\" \"$LOCAL\"
# [diff]
# tool = opendiff-git
#
# With these steps complete, running 'git difftool' from within
# a git working copy will open FileMerge via this script.
#
# IMPLEMENTATION NOTES:
#
# This command will be launched for each changed file. If we just
# invoke opendiff for each invocation, we slowly end up with
# one FileMerge window for each changed file. What we want is
# a single FileMerge window showing the folder structure
# of changed files, from which the user can choose which
# changes to review / revert etc.
#
# To achieve this, we create a single background process that
# waits for the parent process (git) to terminate, before using
# FileMerge to compare temporary left/right folder structures
# containing hardlinks to the original / changed files. Any changes
# will be saved into the git working copy.
#
# Unfortunately, FileMerge breaks a hardlink when saving. So while
# using hardlinks rather than copies is good for performance, they
# act like copies if you edit a file in FileMerge. We handle this
# by monitoring for changes in the files, and then replacing our
# (now effectively copied) temp version of the file with a hardlink
# to the new saved version. This can take a moment to happen so if
# you very quickly reopen a saved file during a FileMerge session you
# may see an out of date version.
#
GIT_FILE="$1"
ORIGINAL_FILE="$2"
# Start in the root checkout path
cd "$(git rev-parse --show-toplevel)"
# Find the topmost 'git' parent process id
export GIT_PID=$(ps -jc | awk '$10 == "git" { print $2; exit }')
#
# Attempt to create a folder for this overall git diff operation.
# We use the shared git parent process ID for this
#
export WORK_DIR_BASE=".opendiff-git-$GIT_PID"
export WORK_DIR="$(pwd)/$WORK_DIR_BASE"
export ORIGINAL_DIR="$WORK_DIR/original"
export MODIFIED_DIR="$WORK_DIR/modified"
export WATCH_PLIST_FILE="$WORK_DIR/watch.plist"
export ON_FILE_CHANGED_SCRIPT="$WORK_DIR/on_file_changed.sh"
export MERGE_DIR="$(pwd)"
function launch_filemerge {
set -o errexit
set -o nounset
# Wait for the git process to exit. Can't use 'wait' because git is not a child process.
while kill -0 $GIT_PID 2>/dev/null
do
sleep 0.1
done
# Finish off the plist file
cat <<- HEREDOC >> "$WATCH_PLIST_FILE"
</array>
</dict>
</plist>
HEREDOC
launchctl load "$WATCH_PLIST_FILE"
# opendiff will block if we pipe its output ...
opendiff "$ORIGINAL_DIR" "$MODIFIED_DIR" -merge "$MERGE_DIR" | cat > /dev/null
launchctl unload "$WATCH_PLIST_FILE"
# Cleanup
rm -rf "$WORK_DIR"
}
export -f launch_filemerge
link_original_file() {
set -o errexit
set -o nounset
original_file="$1"
git_file="$2"
mkdir -p "$ORIGINAL_DIR/$(dirname $git_file)"
ln "$ORIGINAL_FILE" "$ORIGINAL_DIR/$git_file"
}
link_modified_file() {
set -o errexit
set -o nounset
git_file="$1"
mkdir -p "$MODIFIED_DIR/$(dirname $git_file)"
ln "$MERGE_DIR/$git_file" "$MODIFIED_DIR/$git_file"
echo " <string>$MERGE_DIR/$git_file</string>" >> "$WATCH_PLIST_FILE"
}
if mkdir "$WORK_DIR" 2>/dev/null
then
#
# We are the first of possibly many diff-cmd invocations.
# Launch a process that waits for GIT_PID and then opens FileMerge.
#
mkdir "$ORIGINAL_DIR"
mkdir "$MODIFIED_DIR"
#
# To get around FileMerge breaking hardlinks on save,
# we monitor for changes in merged paths and re-hardlink
# them into our modified temp directory. This allows us
# to see the changes if we reopen the diff.
#
# It's a bit convoluted .. FileMerge saves into the git
# working copy. We then look for files that are no longer
# hardlinks in our temp 'modified' folder, and replace them
# with hardlinks back to the git working copy. It could be
# a bit simpler but defensively I didn't want to have this
# script modify files in the working copy.
#
# If we used the git repo as the right hand side of
# FileMerge we'd have to look at tons of irrelevant files.
# 'added to right'.
#
cat <<- HEREDOC > "$ON_FILE_CHANGED_SCRIPT"
#!/bin/bash
set -o errexit
set -o nounset
#
# Each file in 'modified' that is no longer hardlinked
# is an old copy of something that was saved in FileMerge.
# This script replaces that with a new hardlink back to
# the working copy.
#
cd "$MODIFIED_DIR"
for changed_file in \$(find . -type f -links 1)
do
if [ -f "$MERGE_DIR/\$changed_file" ]
then
rm "./\$changed_file"
ln "$MERGE_DIR/\$changed_file" "./\$changed_file"
fi
done
HEREDOC
chmod +x "$ON_FILE_CHANGED_SCRIPT"
# Begin creating the path watching launchctl plist file
cat <<- HEREDOC > "$WATCH_PLIST_FILE"
<?xml version=“1.0” encoding=“UTF-8”?>
<!DOCTYPE plist PUBLIC “-//Apple//DTD PLIST 1.0//EN” “http://www.apple.com/DTDs/PropertyList-1.0.dtd”>
<plist version=“1.0”>
<dict>
<key>Label</key>
<string>au.id.dqh.opendiff-git-$GIT_PID</string>
<key>ProgramArguments</key>
<array>
<string>$ON_FILE_CHANGED_SCRIPT</string>
</array>
<key>WatchPaths</key>
<array>
HEREDOC
#
# Since git difftool won't invoke this script for new files, we
# cheekily add them in ourselves.
#
for untracked_file in $(git ls-files --others --exclude-standard)
do
echo $untracked_file
link_modified_file "$untracked_file"
done
nohup bash -c launch_filemerge </dev/null >/dev/null 2>&1 &
fi
echo $GIT_FILE
GIT_STATUS=$(git -c color.status=false status -s "$GIT_FILE" | awk '{ print $1 }')
# Merge cases with dupicate implementations
case "$GIT_STATUS" in
MM)
GIT_STATUS=M
;;
esac
case "$GIT_STATUS" in
A)
# New file
link_modified_file "$GIT_FILE"
;;
M)
# Modified file
link_original_file "$ORIGINAL_FILE" "$GIT_FILE"
link_modified_file "$GIT_FILE"
;;
D)
# Deleted file
link_original_file "$ORIGINAL_FILE" "$GIT_FILE"
;;
*)
echo "Warning: unhandled git status -s '$GIT_STATUS' for '$GIT_FILE'"
;;
esac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment