Skip to content

Instantly share code, notes, and snippets.

@GoodPie
Created February 2, 2026 01:57
Show Gist options
  • Select an option

  • Save GoodPie/7313728fde68dffdcfb1a5c8eb4eb533 to your computer and use it in GitHub Desktop.

Select an option

Save GoodPie/7313728fde68dffdcfb1a5c8eb4eb533 to your computer and use it in GitHub Desktop.
Output changes to package-lock.json in human readable format
#!/bin/bash
# lockfile-diff.sh — Human-readable package-lock.json diff
#
# Usage:
# ./lockfile-diff.sh # compare HEAD~1 → working tree
# ./lockfile-diff.sh main # compare main → working tree
# ./lockfile-diff.sh main feature-branch # compare two branches
#
# Requires: jq
# Compatible with macOS bash 3.2+
set -euo pipefail
OLD_REF="${1:-HEAD~1}"
NEW_REF="${2:-working}"
# ── Colours ──────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
DIM='\033[2m'
BOLD='\033[1m'
NC='\033[0m'
# ── Preflight ────────────────────────────────────────────────────────
if ! command -v jq &>/dev/null; then
echo "Error: jq is required. Install with: brew install jq / apt install jq"
exit 1
fi
TMP_OLD=$(mktemp)
TMP_NEW=$(mktemp)
TMP_DIFF=$(mktemp)
trap "rm -f $TMP_OLD $TMP_NEW $TMP_DIFF" EXIT
# ── Helpers ──────────────────────────────────────────────────────────
# Extract unique "package-name\tversion" pairs from a lockfile.
# Nested copies (e.g. @jest/core/node_modules/ci-info) are reduced
# to just the package name. Deduplicates so each unique
# package+version pair appears once.
extract_versions() {
jq -r '
.packages
| to_entries[]
| select(.key != "")
| "\(.key)\t\(.value.version // "unknown")"
' | awk -F'\t' '{
path = $1
while (match(path, /node_modules\//) > 0) {
path = substr(path, RSTART + RLENGTH)
}
print path "\t" $2
}' | sort -u
}
get_lockfile() {
local ref="$1"
if [ "$ref" = "working" ]; then
cat package-lock.json
else
git show "${ref}:package-lock.json" 2>/dev/null
fi
}
# ── Extract ──────────────────────────────────────────────────────────
echo -e "${DIM}Reading ${OLD_REF}...${NC}"
get_lockfile "$OLD_REF" | extract_versions > "$TMP_OLD"
echo -e "${DIM}Reading ${NEW_REF}...${NC}"
get_lockfile "$NEW_REF" | extract_versions > "$TMP_NEW"
# ── Diff & classify with awk ─────────────────────────────────────────
# Feed both files into awk, tagged with source, and let awk do all
# the grouping, deduplication, version comparison, and formatting.
awk -F'\t' '
BEGIN {
OFS = "\t"
}
# First file (old) tagged with OLD prefix
FILENAME == ARGV[1] {
pkg = $1; ver = $2
if (!(pkg in old_ver)) old_ver[pkg] = ver
next
}
# Second file (new) tagged with NEW prefix
{
pkg = $1; ver = $2
if (!(pkg in new_ver)) new_ver[pkg] = ver
all[pkg] = 1
}
# Also mark old packages
END {
for (pkg in old_ver) all[pkg] = 1
# Classify each package
n_major = 0; n_minor = 0; n_patch = 0; n_added = 0; n_removed = 0
for (pkg in all) {
old = old_ver[pkg]
new = new_ver[pkg]
if (old == "" && new != "") {
added[n_added++] = sprintf(" %-42s %s", pkg, new)
added_keys[n_added-1] = pkg
} else if (new == "" && old != "") {
removed[n_removed++] = sprintf(" %-42s %s", pkg, old)
removed_keys[n_removed-1] = pkg
} else if (old != new) {
# Parse semver
split(old, ov, ".")
split(new, nv, ".")
if (ov[1] != nv[1]) {
cat = "major"
major[n_major++] = sprintf(" %-42s %-10s → %s", pkg, old, new)
major_keys[n_major-1] = pkg
} else if (ov[2] != nv[2]) {
cat = "minor"
minor[n_minor++] = sprintf(" %-42s %-10s → %s", pkg, old, new)
minor_keys[n_minor-1] = pkg
} else {
cat = "patch"
patch[n_patch++] = sprintf(" %-42s %-10s → %s", pkg, old, new)
patch_keys[n_patch-1] = pkg
}
}
}
# Sort helper: bubble sort each category by key (fine for this size)
sort_and_print(major, major_keys, n_major)
sort_and_print(minor, minor_keys, n_minor)
sort_and_print(patch, patch_keys, n_patch)
sort_and_print(added, added_keys, n_added)
sort_and_print(removed, removed_keys, n_removed)
# Output as tab-separated: category\tline
for (i = 0; i < n_major; i++) print "MAJOR\t" major[i]
for (i = 0; i < n_minor; i++) print "MINOR\t" minor[i]
for (i = 0; i < n_patch; i++) print "PATCH\t" patch[i]
for (i = 0; i < n_added; i++) print "ADDED\t" added[i]
for (i = 0; i < n_removed; i++) print "REMOVED\t" removed[i]
# Summary line
print "SUMMARY\t" n_major "\t" n_minor "\t" n_patch "\t" n_added "\t" n_removed
}
function sort_and_print(arr, keys, n, i, j, tmp, tmpk) {
for (i = 0; i < n - 1; i++) {
for (j = i + 1; j < n; j++) {
if (keys[i] > keys[j]) {
tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp
tmpk = keys[i]; keys[i] = keys[j]; keys[j] = tmpk
}
}
}
}
' "$TMP_OLD" "$TMP_NEW" > "$TMP_DIFF"
# ── Render ───────────────────────────────────────────────────────────
new_label="$NEW_REF"
[ "$NEW_REF" = "working" ] && new_label="working tree"
echo ""
echo -e "${BOLD}┌──────────────────────────────────────────────────────────────┐${NC}"
echo -e "${BOLD}│ Lockfile Diff: ${OLD_REF} → ${new_label}$(printf '%*s' $((42 - ${#OLD_REF} - ${#new_label})) '')│${NC}"
echo -e "${BOLD}└──────────────────────────────────────────────────────────────┘${NC}"
print_section() {
local category="$1" colour="$2" icon="$3" label="$4"
local lines
lines=$(grep "^${category} " "$TMP_DIFF" | cut -f2- || true)
if [ -n "$lines" ]; then
local count
count=$(echo "$lines" | wc -l | tr -d ' ')
echo ""
echo -e " ${colour}${icon} ${label} (${count})${NC}"
echo -e " ${DIM}──────────────────────────────────────────────────────────${NC}"
echo "$lines" | while IFS= read -r line; do
echo -e " ${colour}${line}${NC}"
done
fi
}
print_section "MAJOR" "${RED}${BOLD}" "⚠" "MAJOR — may be breaking"
print_section "MINOR" "$YELLOW" "△" "Minor — new features, backwards compatible"
print_section "PATCH" "$GREEN" "○" "Patch — bug fixes"
print_section "ADDED" "$CYAN" "+" "Added"
print_section "REMOVED" "$DIM" "−" "Removed"
# ── Summary ──────────────────────────────────────────────────────────
summary_line=$(grep "^SUMMARY " "$TMP_DIFF" | head -1)
n_major=$(echo "$summary_line" | cut -f2)
n_minor=$(echo "$summary_line" | cut -f3)
n_patch=$(echo "$summary_line" | cut -f4)
n_added=$(echo "$summary_line" | cut -f5)
n_removed=$(echo "$summary_line" | cut -f6)
total=$((n_major + n_minor + n_patch + n_added + n_removed))
echo ""
echo -e " ${BOLD}${total} packages changed:${NC} ${RED}⚠ ${n_major} major${NC} ${YELLOW}△ ${n_minor} minor${NC} ${GREEN}○ ${n_patch} patch${NC} ${CYAN}+ ${n_added} added${NC} ${DIM}− ${n_removed} removed${NC}"
if [ "$n_major" = "0" ]; then
echo ""
echo -e " ${GREEN}${BOLD}✓ No unexpected breaking changes outside your package.json${NC}"
fi
echo ""
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment