Skip to content

Instantly share code, notes, and snippets.

@benkitzelman
Last active May 10, 2026 22:49
Show Gist options
  • Select an option

  • Save benkitzelman/8d55a9cbe57ce0c4e564dc2273fb6f0d to your computer and use it in GitHub Desktop.

Select an option

Save benkitzelman/8d55a9cbe57ce0c4e564dc2273fb6f0d to your computer and use it in GitHub Desktop.
See all changes in Gems via my.diffend.io or as a git patch
#!/usr/bin/env bash
#
# gemfile-lock-diffend
#
# Diff two Gemfile.lock files and print diffend.io URLs for every gem
# whose version changed (top-level AND transitive). Optionally
# emit a unified diff for each changed gem.
#
# Usage:
# gemfile-lock-diffend # git HEAD:Gemfile.lock vs ./Gemfile.lock
# gemfile-lock-diffend <git-ref> # <ref>:Gemfile.lock vs ./Gemfile.lock
# gemfile-lock-diffend <fileA> <fileB> # explicit two files
#
# Examples:
# gemfile-lock-diffend # get my.diffend.io urls to view all gem changes for uncommitted updates to Gemfile.lock
# gemfile-lock-diffend --patch | less # view (as a unified diff) changes for uncommitted updates to Gemfile.lock for all updated gems
#
# Options (anywhere in args):
# --open open each diffend URL in default browser
# --compare also run `gem compare <name> <old> <new>` (needs gem-compare)
# --patch emit combined unified diff of every changed gem to stdout
# (uses `gem fetch` + `gem unpack` + `diff -ruN`).
# URL summary goes to stderr so you can redirect:
# gemfile-lock-diffend --patch > changes.patch
# -h, --help show this help
#
set -euo pipefail
# Exit silently if downstream consumer (e.g. `less`) closes the pipe.
trap 'exit 0' PIPE
DIFFEND_BASE="https://my.diffend.io/gems"
usage() {
sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'
}
OPEN=0
COMPARE=0
PATCH=0
POSITIONAL=()
for arg in "$@"; do
case "$arg" in
--open) OPEN=1 ;;
--compare) COMPARE=1 ;;
--patch) PATCH=1 ;;
-h|--help) usage; exit 0 ;;
--*) echo "unknown option: $arg" >&2; exit 2 ;;
*) POSITIONAL+=("$arg") ;;
esac
done
# In --patch mode, send the URL summary to stderr so stdout is a clean patch.
if [[ $PATCH -eq 1 ]]; then SUMMARY_FD=2; else SUMMARY_FD=1; fi
emit_patch_for_gem() {
local name="$1" old="$2" new="$3"
local d; d="$(mktemp -d)"
(
cd "$d"
# Prefer the ruby platform for a clean source diff; fall back to default.
gem fetch "$name" -v "$old" --platform=ruby --quiet >/dev/null 2>&1 \
|| gem fetch "$name" -v "$old" --quiet >/dev/null 2>&1 || true
gem fetch "$name" -v "$new" --platform=ruby --quiet >/dev/null 2>&1 \
|| gem fetch "$name" -v "$new" --quiet >/dev/null 2>&1 || true
mkdir -p old new
local old_gem new_gem
old_gem="$(ls "${name}-${old}"*.gem 2>/dev/null | head -1 || true)"
new_gem="$(ls "${name}-${new}"*.gem 2>/dev/null | head -1 || true)"
if [[ -z "$old_gem" || -z "$new_gem" ]]; then
echo "# could not fetch ${name} ${old} and/or ${new} from rubygems" >&2
return 0
fi
gem unpack "$old_gem" --target old >/dev/null 2>&1 || true
gem unpack "$new_gem" --target new >/dev/null 2>&1 || true
)
local old_dir new_dir
old_dir="$(ls -d "$d/old/${name}-${old}"* 2>/dev/null | head -1 || true)"
new_dir="$(ls -d "$d/new/${name}-${new}"* 2>/dev/null | head -1 || true)"
printf '\n############################################################\n'
printf '# %s: %s -> %s\n' "$name" "$old" "$new"
printf '# %s/%s/%s/%s\n' "$DIFFEND_BASE" "$name" "$old" "$new"
printf '############################################################\n'
if [[ -n "$old_dir" && -n "$new_dir" ]]; then
diff -ruN \
--label "a/${name}-${old}" \
--label "b/${name}-${new}" \
"$old_dir" "$new_dir" \
| sed -E "s|${old_dir}|a/${name}-${old}|g; s|${new_dir}|b/${name}-${new}|g" \
|| true
else
echo "# unpack failed; skipping diff"
fi
rm -rf "$d"
}
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
resolve_inputs() {
local n=${#POSITIONAL[@]}
if [[ $n -eq 0 || $n -eq 1 ]]; then
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
echo "not in a git repo and no files given" >&2; exit 2; }
local toplevel; toplevel="$(git rev-parse --show-toplevel)"
local lock="${toplevel}/Gemfile.lock"
local ref="${POSITIONAL[0]:-HEAD}"
git show "${ref}:Gemfile.lock" > "$tmpdir/old" 2>/dev/null || {
echo "no Gemfile.lock at ${ref}" >&2; exit 2; }
[[ -f "$lock" ]] || { echo "no Gemfile.lock at ${lock}" >&2; exit 2; }
cp "$lock" "$tmpdir/new"
elif [[ $n -eq 2 ]]; then
cp "${POSITIONAL[0]}" "$tmpdir/old"
cp "${POSITIONAL[1]}" "$tmpdir/new"
else
usage; exit 2
fi
}
# Extract "name<TAB>version" lines from a Gemfile.lock.
# Matches indented " name (version)" entries inside GEM/GIT/PATH specs sections.
extract_versions() {
awk '
/^[A-Z]/ { section=$1; next }
section=="GEM" || section=="GIT" || section=="PATH" {
# spec lines: 4-space indent, "name (version)"
if (match($0, /^ [a-zA-Z0-9._-]+ \([^)]+\)$/)) {
line=$0
sub(/^ /, "", line)
# name
n=line
sub(/ \(.*$/, "", n)
# version (strip leading "= ", refs etc.)
v=line
sub(/^[^(]*\(/, "", v)
sub(/\).*$/, "", v)
# strip platform suffix (e.g. "1.19.2-arm64-darwin" -> "1.19.2");
# gem versions never contain "-", so anything after "-" is platform.
sub(/-.*$/, "", v)
print n "\t" v
}
}
' "$1" | sort -u
}
resolve_inputs
old_list="$tmpdir/old.list"
new_list="$tmpdir/new.list"
extract_versions "$tmpdir/old" > "$old_list"
extract_versions "$tmpdir/new" > "$new_list"
# Build maps and diff
join -t $'\t' -a1 -a2 -e '-' -o '0,1.2,2.2' "$old_list" "$new_list" \
| awk -F'\t' '$2 != $3' > "$tmpdir/changes"
if [[ ! -s "$tmpdir/changes" ]]; then
echo "no gem version changes detected"
exit 0
fi
# Pretty column widths
maxname=$(awk -F'\t' '{print length($1)}' "$tmpdir/changes" | sort -nr | head -1)
maxold=$(awk -F'\t' '{print length($2)}' "$tmpdir/changes" | sort -nr | head -1)
maxnew=$(awk -F'\t' '{print length($3)}' "$tmpdir/changes" | sort -nr | head -1)
opener=""
if [[ $OPEN -eq 1 ]]; then
if command -v open >/dev/null 2>&1; then opener="open"
elif command -v xdg-open >/dev/null 2>&1; then opener="xdg-open"
fi
fi
while IFS=$'\t' read -r name old new; do
if [[ "$old" == "-" ]]; then
url="${DIFFEND_BASE}/${name}/${new}"
label="ADDED"
elif [[ "$new" == "-" ]]; then
url="${DIFFEND_BASE}/${name}/${old}"
label="REMOVED"
else
url="${DIFFEND_BASE}/${name}/${old}/${new}"
label=""
fi
printf "%-${maxname}s %-${maxold}s -> %-${maxnew}s %s%s\n" \
"$name" "$old" "$new" "$url" "${label:+ [$label]}" >&${SUMMARY_FD}
if [[ -n "$opener" && "$old" != "-" && "$new" != "-" ]]; then
"$opener" "$url" >/dev/null 2>&1 || true
fi
if [[ $COMPARE -eq 1 && "$old" != "-" && "$new" != "-" ]]; then
if command -v gem >/dev/null 2>&1; then
echo "--- gem compare $name $old $new ---"
gem compare "$name" "$old" "$new" || true
echo
fi
fi
if [[ $PATCH -eq 1 && "$old" != "-" && "$new" != "-" ]]; then
emit_patch_for_gem "$name" "$old" "$new"
fi
done < "$tmpdir/changes"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment