feat: add conventional commit

This commit is contained in:
Ray Andrew 2025-12-04 21:12:49 -06:00
parent d0b52d9c38
commit dc3ee6130e
Signed by: rayandrew
SSH key fingerprint: SHA256:XYrYrxF0Z3A72n8P/p6mqPRNQZT22F88XcLsG+kX4xw
5 changed files with 305 additions and 9 deletions

130
bin/gc Executable file
View file

@ -0,0 +1,130 @@
#!/usr/bin/env bash
# Conventional commit helper
# Usage: gc [type] [message] [-s scope] [-b] [-B body]
# gc (interactive mode)
set -o errexit
set -o nounset
TYPES="feat fix docs style refactor test chore perf ci build"
usage() {
echo "Usage: gc [type] [message] [-s scope] [-b] [-B body]"
echo " gc (interactive mode)"
echo ""
echo "Options:"
echo " -s, --scope Scope of the change"
echo " -b, --breaking Mark as breaking change (!)"
echo " -B, --body Commit body (longer description)"
echo ""
echo "Types: $TYPES"
echo ""
echo "Examples:"
echo " gc feat 'add user auth'"
echo " gc fix 'resolve crash' -s api # fix(api): resolve crash"
echo " gc feat 'new api' -b # feat!: new api"
echo " gc feat 'new api' -s auth -b # feat(auth)!: new api"
echo " gc feat 'big change' -B 'Details here'"
exit 1
}
build_message() {
local type="$1"
local scope="$2"
local breaking="$3"
local msg="$4"
local body="$5"
local commit_msg="$type"
if [ -n "$scope" ]; then
commit_msg+="($scope)"
fi
if [ "$breaking" = "true" ]; then
commit_msg+="!"
fi
commit_msg+=": $msg"
if [ -n "$body" ]; then
commit_msg+=$'\n\n'"$body"
fi
echo "$commit_msg"
}
# Interactive mode if no args
if [ $# -eq 0 ]; then
echo "Types: $TYPES"
printf "Type: "
read -r type
printf "Scope (optional): "
read -r scope
printf "Breaking change? [y/N]: "
read -r breaking_input
printf "Message: "
read -r msg
printf "Body (optional, enter for none): "
read -r body
breaking="false"
if [[ $breaking_input =~ ^[Yy] ]]; then
breaking="true"
fi
commit_msg=$(build_message "$type" "$scope" "$breaking" "$msg" "$body")
git commit -m "$commit_msg"
exit 0
fi
# Parse arguments
type=""
msg=""
scope=""
breaking="false"
body=""
while [ $# -gt 0 ]; do
case "$1" in
-s | --scope)
scope="$2"
shift 2
;;
-b | --breaking)
breaking="true"
shift
;;
-B | --body)
body="$2"
shift 2
;;
-h | --help)
usage
;;
*)
if [ -z "$type" ]; then
type="$1"
else
msg="$1"
fi
shift
;;
esac
done
# Validate type
if ! echo "$TYPES" | grep -qw "$type"; then
echo "Invalid type: $type"
echo "Valid types: $TYPES"
exit 1
fi
if [ -z "$msg" ]; then
echo "Message required"
usage
fi
commit_msg=$(build_message "$type" "$scope" "$breaking" "$msg" "$body")
git commit -m "$commit_msg"

105
bin/gc-changelog Executable file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Generate changelog from conventional commits
# Usage: gc-changelog [from_ref] [to_ref]
# gc-changelog # all commits
# gc-changelog v1.0.0 # from tag to HEAD
# gc-changelog v1.0.0 v2.0.0 # between tags
set -o errexit
set -o nounset
from_ref=${1:-}
to_ref=${2:-HEAD}
if [ -n "$from_ref" ]; then
range="$from_ref..$to_ref"
else
range=""
fi
# Collect commits by type
declare -A titles=(
[feat]="Features"
[fix]="Bug Fixes"
[docs]="Documentation"
[style]="Styles"
[refactor]="Refactoring"
[test]="Tests"
[chore]="Chores"
[perf]="Performance"
[ci]="CI"
[build]="Build"
)
declare -A commits
declare -a breaking_changes
for type in feat fix docs style refactor test chore perf ci build; do
commits[$type]=""
done
# Parse commits
# Full pattern: type(scope)!: description
while IFS= read -r line; do
[ -z "$line" ] && continue
msg=$(echo "$line" | cut -d' ' -f2-)
for type in feat fix docs style refactor test chore perf ci build; do
# Match: type, optional (scope), optional !, colon, space
if echo "$msg" | grep -qE "^$type(\([^)]+\))?!?: "; then
is_breaking=false
if echo "$msg" | grep -qE "^$type(\([^)]+\))?!:"; then
is_breaking=true
fi
# Extract scope and description
if echo "$msg" | grep -qE "^$type\([^)]+\)"; then
scope=$(echo "$msg" | sed -E "s/^$type\(([^)]+)\)!?: .*/\1/")
desc=$(echo "$msg" | sed -E "s/^$type\([^)]+\)!?: //")
else
scope=""
desc=$(echo "$msg" | sed -E "s/^$type!?: //")
fi
# Format the entry
if [ -n "$scope" ]; then
entry="- **$scope**: $desc"
else
entry="- $desc"
fi
if [ "$is_breaking" = true ]; then
breaking_changes+=("$entry")
fi
commits[$type]+="$entry"$'\n'
break
fi
done
done < <(git log --oneline $range)
# Output changelog
echo "# Changelog"
echo ""
# Breaking changes first
if [ ${#breaking_changes[@]} -gt 0 ]; then
echo "## BREAKING CHANGES"
echo ""
for change in "${breaking_changes[@]}"; do
echo "$change"
done
echo ""
fi
# Then by type
for type in feat fix perf refactor docs style test chore ci build; do
if [ -n "${commits[$type]}" ]; then
echo "## ${titles[$type]}"
echo ""
echo -n "${commits[$type]}"
echo ""
fi
done

41
bin/gc-check Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Validate conventional commit messages in git history
# Usage: gc-check [number_of_commits]
set -o errexit
set -o nounset
# Full conventional commit regex:
# type(optional-scope)optional-!: description
COMMIT_REGEX='^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\([^)]+\))?!?: .+'
count=${1:-10}
errors=0
echo "Checking last $count commits..."
echo ""
while IFS= read -r line; do
hash=$(echo "$line" | cut -d' ' -f1)
msg=$(echo "$line" | cut -d' ' -f2-)
if echo "$msg" | grep -qE "$COMMIT_REGEX"; then
if echo "$msg" | grep -qE '^[^:]+!:'; then
echo "✓ $hash $msg (BREAKING)"
else
echo "✓ $hash $msg"
fi
else
echo "✗ $hash $msg"
errors=$((errors + 1))
fi
done < <(git log --oneline -n "$count")
echo ""
if [ $errors -gt 0 ]; then
echo "$errors invalid commit(s) found"
exit 1
else
echo "All commits valid"
fi

View file

@ -40,7 +40,7 @@ elif [ "$1" == "off" ]; then
done <"$BACKUP_FILE" done <"$BACKUP_FILE"
# Restore original wallpaper # Restore original wallpaper
osascript -e 'tell application "System Events" to set picture of every desktop to "/Users/rayandrew/Pictures/Wallpapers/bluering.png"' >/dev/null 2>&1 osascript -e 'tell application "System Events" to set picture of every desktop to "/Users/rayandrew/Pictures/Wallpapers/420322.jpg"' >/dev/null 2>&1
rm "$BACKUP_FILE" rm "$BACKUP_FILE"
aerospace reload-config aerospace reload-config

View file

@ -34,7 +34,8 @@ else
echo "body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 30px 20px; max-width: 800px; margin: 0 auto; background: var(--bg); color: var(--text); transition: all 0.2s ease; line-height: 1.5; }" echo "body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 30px 20px; max-width: 800px; margin: 0 auto; background: var(--bg); color: var(--text); transition: all 0.2s ease; line-height: 1.5; }"
echo "pre { white-space: pre-wrap; word-wrap: break-word; }" echo "pre { white-space: pre-wrap; word-wrap: break-word; }"
echo ".headers { background: var(--header-bg); padding: 20px 25px; margin-bottom: 25px; border-radius: 12px; border: 1px solid var(--border); box-shadow: 0 2px 8px var(--shadow); }" echo ".headers { background: var(--header-bg); padding: 20px 25px; margin-bottom: 25px; border-radius: 12px; border: 1px solid var(--border); box-shadow: 0 2px 8px var(--shadow); }"
echo ".headers p { margin: 8px 0; display: grid; grid-template-columns: 70px 1fr; gap: 8px; align-items: baseline; }" echo ".headers p { margin: 8px 0; display: grid; grid-template-columns: 80px 1fr; gap: 8px; align-items: baseline; }"
echo ".headers strong { white-space: nowrap; }"
echo ".headers strong { color: var(--muted); font-weight: 500; }" echo ".headers strong { color: var(--muted); font-weight: 500; }"
echo ".headers .addr-list { display: flex; flex-wrap: wrap; gap: 6px; }" echo ".headers .addr-list { display: flex; flex-wrap: wrap; gap: 6px; }"
echo ".headers .addr { padding: 4px 10px; background: var(--bg); border-radius: 6px; font-size: 0.95em; }" echo ".headers .addr { padding: 4px 10px; background: var(--bg); border-radius: 6px; font-size: 0.95em; }"
@ -53,7 +54,10 @@ else
echo "a { color: var(--link); text-decoration: none; } a:hover { text-decoration: underline; }" echo "a { color: var(--link); text-decoration: none; } a:hover { text-decoration: underline; }"
echo ".toggle { position: fixed; top: 20px; right: 20px; width: 44px; height: 44px; border: 1px solid var(--border); border-radius: 50%; cursor: pointer; background: var(--header-bg); color: var(--text); font-size: 20px; transition: all 0.2s; box-shadow: 0 2px 8px var(--shadow); }" echo ".toggle { position: fixed; top: 20px; right: 20px; width: 44px; height: 44px; border: 1px solid var(--border); border-radius: 50%; cursor: pointer; background: var(--header-bg); color: var(--text); font-size: 20px; transition: all 0.2s; box-shadow: 0 2px 8px var(--shadow); }"
echo ".toggle:hover { transform: scale(1.05); box-shadow: 0 4px 12px var(--shadow); }" echo ".toggle:hover { transform: scale(1.05); box-shadow: 0 4px 12px var(--shadow); }"
echo ".sun { display: none; } .dark .sun { display: inline; } .dark .moon { display: none; }" echo ".toggle .icon { display: none; }"
echo ".toggle.auto .icon-auto { display: inline; }"
echo ".toggle.light .icon-light { display: inline; }"
echo ".toggle.dark .icon-dark { display: inline; }"
echo ".attachments { margin-top: 25px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }" echo ".attachments { margin-top: 25px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }"
echo ".attachments-header { padding: 12px 20px; background: var(--header-bg); cursor: pointer; font-weight: 500; color: var(--muted); }" echo ".attachments-header { padding: 12px 20px; background: var(--header-bg); cursor: pointer; font-weight: 500; color: var(--muted); }"
echo ".attachments-header:hover { background: var(--border); }" echo ".attachments-header:hover { background: var(--border); }"
@ -72,9 +76,14 @@ else
echo ".lightbox-close:hover { background: rgba(0,0,0,0.8); }" echo ".lightbox-close:hover { background: rgba(0,0,0,0.8); }"
echo "</style></head><body>" echo "</style></head><body>"
echo '<div class="lightbox" onclick="closeLightbox()"><span class="lightbox-close">&times;</span><img id="lightbox-img" src="" alt=""></div>' echo '<div class="lightbox" onclick="closeLightbox()"><span class="lightbox-close">&times;</span><img id="lightbox-img" src="" alt=""></div>'
echo '<button class="toggle" onclick="toggleTheme()" title="Toggle theme"><span class="moon">&#9790;</span><span class="sun">&#9728;</span></button>' echo '<button class="toggle auto" onclick="toggleTheme()" title="Theme: auto"><span class="icon icon-auto">&#9681;</span><span class="icon icon-light">&#9728;</span><span class="icon icon-dark">&#9790;</span></button>'
echo "<script>" echo "<script>"
echo "function toggleTheme() { document.documentElement.classList.toggle('dark'); }" echo "var themes = ['auto', 'light', 'dark'];"
echo "function getSystemTheme() { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }"
echo "function applyTheme(t) { if (t === 'auto') t = getSystemTheme(); document.documentElement.classList.toggle('dark', t === 'dark'); }"
echo "function toggleTheme() { var curr = localStorage.getItem('email-theme') || 'auto'; var next = themes[(themes.indexOf(curr) + 1) % 3]; localStorage.setItem('email-theme', next); applyTheme(next); updateToggleBtn(next); }"
echo "function updateToggleBtn(t) { var btn = document.querySelector('.toggle'); btn.className = 'toggle ' + t; btn.title = 'Theme: ' + t + ' (click to change)'; }"
echo "(function() { var t = localStorage.getItem('email-theme') || 'auto'; applyTheme(t); updateToggleBtn(t); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() { if ((localStorage.getItem('email-theme') || 'auto') === 'auto') applyTheme('auto'); }); })();"
echo "function toggleQuote(el) { var c = el.nextElementSibling; c.classList.toggle('show'); el.textContent = c.classList.contains('show') ? '▼ Hide quoted text' : '▶ Show quoted text'; }" echo "function toggleQuote(el) { var c = el.nextElementSibling; c.classList.toggle('show'); el.textContent = c.classList.contains('show') ? '▼ Hide quoted text' : '▶ Show quoted text'; }"
echo "function toggleAttachments(el) { var c = el.nextElementSibling; c.classList.toggle('show'); var n = el.textContent.match(/\\d+/)[0]; el.textContent = c.classList.contains('show') ? '▼ Attachments (' + n + ')' : '▶ Attachments (' + n + ')'; }" echo "function toggleAttachments(el) { var c = el.nextElementSibling; c.classList.toggle('show'); var n = el.textContent.match(/\\d+/)[0]; el.textContent = c.classList.contains('show') ? '▼ Attachments (' + n + ')' : '▶ Attachments (' + n + ')'; }"
echo "function openLightbox(src) { document.getElementById('lightbox-img').src = src; document.querySelector('.lightbox').classList.add('show'); document.body.style.overflow = 'hidden'; }" echo "function openLightbox(src) { document.getElementById('lightbox-img').src = src; document.querySelector('.lightbox').classList.add('show'); document.body.style.overflow = 'hidden'; }"
@ -233,7 +242,8 @@ else
result = before "<a href=\"mailto:" email "\">" email "</a>" after result = before "<a href=\"mailto:" email "\">" email "</a>" after
} }
# Third: Handle text&lt;https://url&gt; - text becomes link text # Third: Handle text&lt;https://url&gt; - text becomes link text
while (match(result, /[A-Za-z0-9_-]+&lt;https?:\/\/[^&]+&gt;/)) { # Text can include letters, numbers, and punctuation like /
while (match(result, /[A-Za-z0-9_\/.,-]+&lt;https?:\/\/[^&]+&gt;/)) {
before = substr(result, 1, RSTART-1) before = substr(result, 1, RSTART-1)
full = substr(result, RSTART, RLENGTH) full = substr(result, RSTART, RLENGTH)
# Find where &lt; starts # Find where &lt; starts
@ -251,8 +261,8 @@ else
after = substr(result, RSTART+RLENGTH) after = substr(result, RSTART+RLENGTH)
result = before "<a href=\"" url "\" target=\"_blank\">" url "</a>" after result = before "<a href=\"" url "\" target=\"_blank\">" url "</a>" after
} }
# Fifth: Handle plain URLs (not already in href) # Fifth: Handle plain https:// URLs (not already in href)
while (match(result, /https?:\/\/[^ &<>"\n\t]+/)) { while (match(result, /https?:\/\/[A-Za-z0-9][^ \t\n"<>&]*/)) {
before = substr(result, 1, RSTART-1) before = substr(result, 1, RSTART-1)
if (before ~ /href="$/) break if (before ~ /href="$/) break
url = substr(result, RSTART, RLENGTH) url = substr(result, RSTART, RLENGTH)
@ -261,7 +271,17 @@ else
sub(/[.,;:!?)]+$/, "", url) sub(/[.,;:!?)]+$/, "", url)
result = before "<a href=\"" url "\" target=\"_blank\">" url "</a>" after result = before "<a href=\"" url "\" target=\"_blank\">" url "</a>" after
} }
# Sixth: Handle [cid:image] inline images # Sixth: Handle www. URLs (without https://)
while (match(result, /www\.[A-Za-z0-9][^ \t\n"<>&]*/)) {
before = substr(result, 1, RSTART-1)
if (before ~ /href="$/ || before ~ /\/\/$/) break
url = substr(result, RSTART, RLENGTH)
after = substr(result, RSTART+RLENGTH)
# Clean trailing punctuation
sub(/[.,;:!?)]+$/, "", url)
result = before "<a href=\"https://" url "\" target=\"_blank\">" url "</a>" after
}
# Seventh: Handle [cid:image] inline images
while (match(result, /\[cid:[^\]]+\]/)) { while (match(result, /\[cid:[^\]]+\]/)) {
before = substr(result, 1, RSTART-1) before = substr(result, 1, RSTART-1)
# Extract image name from cid reference # Extract image name from cid reference