Created
November 10, 2022 03:14
-
-
Save dqh-au/7426c2268d11a6c07671e3d742e09fff to your computer and use it in GitHub Desktop.
Better macOS git difftool + FileMerge integration
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
# | |
# Copyright 2022 David Hogan <[email protected]> | |
# | |
# 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