Created
February 2, 2026 01:57
-
-
Save GoodPie/7313728fde68dffdcfb1a5c8eb4eb533 to your computer and use it in GitHub Desktop.
Output changes to package-lock.json in human readable format
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
| #!/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