Last active
May 10, 2026 22:49
-
-
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
This file contains hidden or 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
| #!/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