Skip to content

Instantly share code, notes, and snippets.

@izackp
Last active May 26, 2026 20:21
Show Gist options
  • Select an option

  • Save izackp/dad8090f25ed0d3cd3b2a15fdcb14dac to your computer and use it in GitHub Desktop.

Select an option

Save izackp/dad8090f25ed0d3cd3b2a15fdcb14dac to your computer and use it in GitHub Desktop.
gist_sync.sh
#!/usr/bin/env bash
# gist: https://gist.github.com/izackp/dad8090f25ed0d3cd3b2a15fdcb14dac
# gist_sync.sh — bidirectional file <-> GitHub gist sync helper.
# See `gist_sync.sh help` for full usage.
#
# Version: 0.1.0
# Updated: 2026/05/26
# Copyright: (c) 2026 Isaac Paul
# License: https://gist.github.com/izackp/5eba3008d3311e897c7486f3dee5d604
#
# Changelog:
# 0.1.0 (2026/05/26) — initial release.
set -euo pipefail
GIST_SYNC_VERSION="0.1.0"
# ---------------------------------------------------------------------------
# Help text
# ---------------------------------------------------------------------------
HELP_TEXT=$(cat <<'HELP'
gist_sync.sh — bidirectional file <-> GitHub gist sync
SYNOPSIS
gist_sync.sh <command> [args]
COMMANDS
create [-p] <file>
Create a new gist from <file>. Default is secret; -p makes it public.
Inserts gist URL into <file> as comment and pushes stamped version
back to gist. Refuses if <file> already carries a gist URL comment.
If stamp-push fails the gist + local are left mismatched and the
command exits 1 — rerun `up <file>` to reconcile.
up [-f] <file>
Push <file> to its gist (URL read from header comment). Refuses if
gist diverged from cached baseline (remote has changes you have not
pulled). Use -f to overwrite. Skips push when local == gist.
down [-f] <file>
Pull gist into <file>. Refuses if local diverged from cached baseline
(you have unpushed edits). Use -f to discard local. Backup written
to <file>.bak before overwrite. Skips overwrite when local == gist.
sync
Iterate every path in cache, pick `up`, `down`, or `conflict` per
file by comparing three hashes (cached baseline, local, gist).
Per-file errors do not abort the loop; summary printed at end.
help, --help, -h
Print this text. Also printed when no command is given.
version, --version, -V
Print version and exit.
CACHE
Location: ${GIST_SYNC_HOME:-$HOME/.gist_sync}/cache.tsv
Format: <absolute_path>\t<sha256_of_last_synced_content>
TSV chosen over CSV because paths commonly contain commas. Paths with
literal tabs or newlines are refused.
URL COMMENT PLACEMENT
URL lands on line 2.
- Shebang on line 1:
line 1: #!/usr/bin/env bash (untouched)
line 2: # gist: <url> (inserted)
- No shebang:
line 1: (blank, inserted)
line 2: # gist: <url> (inserted)
Comment marker by extension / filename:
# .sh .py .rb .yml .yaml .toml .gitignore .tf .hcl .conf .cfg .ini
Makefile Dockerfile .bashrc .zshrc .profile .bash_profile .env
// .kt .swift .c .h .cpp .java .js .ts .jsx .tsx .rs .go .scala .cs
<!-- ... --> .md .html .xml .svg
.json is refused (no comment syntax). Other unknown extensions refused.
SYNC DECISION TABLE
Given local_hash, gist_hash, cached_hash:
local == cache && gist == cache -> unchanged (noop)
local != cache && gist == cache -> up
local == cache && gist != cache -> down
local != cache && gist != cache &&
local == gist -> converged (cache refreshed)
local != cache && gist != cache &&
local != gist -> conflict (no transfer)
CONCURRENCY
A directory-based lock at $CACHE_DIR/lock.d serializes the script. Stale
lock can be removed with: rmdir $CACHE_DIR/lock.d
ENVIRONMENT
GIST_SYNC_HOME Override cache directory (default $HOME/.gist_sync).
EXIT CODES
0 ok
1 validation / runtime error
2 usage error (bad args, missing gh, unknown comment style)
HELP
)
# ---------------------------------------------------------------------------
# Constants + tmp/lock lifecycle
# ---------------------------------------------------------------------------
CACHE_DIR="${GIST_SYNC_HOME:-$HOME/.gist_sync}"
CACHE_TSV="$CACHE_DIR/cache.tsv"
LOCK_DIR="$CACHE_DIR/lock.d"
TMP_FILES=()
LOCK_HELD=0
cleanup_exit() {
local f
if [ "${#TMP_FILES[@]}" -gt 0 ]; then
for f in "${TMP_FILES[@]}"; do
[ -n "$f" ] && rm -f -- "$f"
done
fi
if [ "$LOCK_HELD" = "1" ]; then
rmdir -- "$LOCK_DIR" 2>/dev/null || true
fi
}
trap cleanup_exit EXIT
mktmp() {
local f
f=$(mktemp)
TMP_FILES+=("$f")
printf '%s\n' "$f"
}
acquire_lock() {
local tries=0
while ! mkdir -- "$LOCK_DIR" 2>/dev/null; do
tries=$((tries + 1))
if [ "$tries" -ge 50 ]; then
echo "gist_sync: cache lock $LOCK_DIR held; remove if stale: rmdir $LOCK_DIR" >&2
exit 1
fi
sleep 0.1
done
LOCK_HELD=1
}
ensure_cache() {
mkdir -p -- "$CACHE_DIR"
[ -e "$CACHE_TSV" ] || : >"$CACHE_TSV"
}
require_gh() {
command -v gh >/dev/null 2>&1 || {
echo "gist_sync: gh CLI not found on PATH" >&2
exit 2
}
}
# ---------------------------------------------------------------------------
# gh retry wrappers (handle transient network errors)
# ---------------------------------------------------------------------------
# Run `gh <args>` up to 3 times with exponential backoff, redirecting stdout
# to <outfile>. The outfile is truncated before each attempt.
gh_retry_to_file() {
local outfile=$1; shift
local attempts=3 delay=1 i=1 rc=0
while [ "$i" -le "$attempts" ]; do
: >"$outfile"
if gh "$@" >"$outfile" 2>/dev/null; then
return 0
fi
rc=$?
if [ "$i" -lt "$attempts" ]; then
sleep "$delay"
delay=$((delay * 2))
fi
i=$((i + 1))
done
return "$rc"
}
# Run `gh <args>` up to 3 times. On success print captured stdout+stderr.
# On final failure print captured output to stderr and return non-zero.
gh_retry_capture() {
local attempts=3 delay=1 i=1 rc=0
local out=""
while [ "$i" -le "$attempts" ]; do
if out=$(gh "$@" 2>&1); then
printf '%s' "$out"
return 0
fi
rc=$?
if [ "$i" -lt "$attempts" ]; then
sleep "$delay"
delay=$((delay * 2))
fi
i=$((i + 1))
done
printf '%s' "$out" >&2
return "$rc"
}
# ---------------------------------------------------------------------------
# Path + validation helpers
# ---------------------------------------------------------------------------
abs_path() {
local f=$1
(cd -- "$(dirname -- "$f")" && printf '%s/%s\n' "$PWD" "$(basename -- "$f")")
}
validate_path() {
case "$1" in
*$'\t'*|*$'\n'*)
echo "gist_sync: path contains tab/newline, cannot store in cache: '$1'" >&2
exit 2
;;
esac
}
# ---------------------------------------------------------------------------
# Comment-style detection
# ---------------------------------------------------------------------------
detect_comment() {
local file=$1
local base ext
base=$(basename -- "$file")
ext="${base##*.}"
# Filename-based matches (no extension or special).
case "$base" in
Makefile|makefile|GNUmakefile|Dockerfile) echo "#"; return ;;
.bashrc|.zshrc|.profile|.bash_profile|.env) echo "#"; return ;;
.env.*) echo "#"; return ;;
esac
# If no real extension (no dot), refuse below.
if [ "$ext" = "$base" ]; then
echo "gist_sync: no extension on '$base' — add a rule in detect_comment()" >&2
exit 2
fi
case "$ext" in
sh|py|rb|yml|yaml|toml|gitignore|tf|hcl|conf|cfg|ini) echo "#" ;;
kt|swift|c|h|cpp|java|js|ts|jsx|tsx|rs|go|scala|cs) echo "//" ;;
md|html|xml|svg) echo "html" ;;
json)
echo "gist_sync: JSON has no comment syntax — cannot stamp URL into '$file'" >&2
exit 2
;;
*)
echo "gist_sync: unknown comment style for extension '.$ext' — add to detect_comment()" >&2
exit 2
;;
esac
}
format_comment_line() {
local style=$1 url=$2
case "$style" in
"#") printf '# gist: %s' "$url" ;;
"//") printf '// gist: %s' "$url" ;;
"html") printf '<!-- gist: %s -->' "$url" ;;
esac
}
# ---------------------------------------------------------------------------
# Gist URL extraction / id parsing
# ---------------------------------------------------------------------------
extract_gist_url() {
local file=$1
{ head -n 5 -- "$file" 2>/dev/null \
| grep -oE 'https://gist\.github\.com/[^[:space:]]+' \
| sed -E 's/[->[:space:]]+$//' \
| head -n 1; } || true
}
gist_id_from_url() {
local url=$1 id
url="${url%/}"
id="${url##*/}"
if [ -z "$id" ] || [ "$id" = "$url" ]; then
echo "gist_sync: malformed gist URL '$1'" >&2
exit 1
fi
printf '%s\n' "$id"
}
# ---------------------------------------------------------------------------
# Cache operations (TSV)
# ---------------------------------------------------------------------------
cache_get_hash() {
local path=$1
awk -F'\t' -v p="$path" '$1==p {print $2; exit}' "$CACHE_TSV" 2>/dev/null || true
}
cache_upsert() {
local path=$1 hash=$2
validate_path "$path"
# mktemp inside CACHE_DIR so the final `mv` is same-filesystem and atomic.
local tmp
tmp=$(mktemp -- "$CACHE_TSV.XXXXXX")
TMP_FILES+=("$tmp")
awk -F'\t' -v p="$path" '$1!=p' "$CACHE_TSV" >"$tmp"
printf '%s\t%s\n' "$path" "$hash" >>"$tmp"
mv -- "$tmp" "$CACHE_TSV"
}
cache_rows() {
awk -F'\t' '/^[[:space:]]*#/ {next} NF>=2 {printf "%s\t%s\n", $1, $2}' "$CACHE_TSV"
}
# ---------------------------------------------------------------------------
# Hash + diff helpers
# ---------------------------------------------------------------------------
sha_file() {
shasum -a 256 -- "$1" | awk '{print $1}'
}
print_diff() {
local lb=$1 fb=$2 la=$3 fa=$4
diff -u --label "$lb" --label "$la" "$fb" "$fa" || true
}
# ---------------------------------------------------------------------------
# URL comment insertion
# ---------------------------------------------------------------------------
insert_url_comment() {
local file=$1 url=$2
local style first_line comment_line tmp
style=$(detect_comment "$file")
comment_line=$(format_comment_line "$style" "$url")
first_line=$(head -n 1 -- "$file" 2>/dev/null || true)
tmp=$(mktmp)
case "$first_line" in
"#!"*)
printf '%s\n%s\n' "$first_line" "$comment_line" >"$tmp"
tail -n +2 -- "$file" >>"$tmp"
;;
*)
printf '\n%s\n' "$comment_line" >"$tmp"
cat -- "$file" >>"$tmp"
;;
esac
# Overwrite content into existing inode so mode + ownership survive.
cp -- "$tmp" "$file"
}
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
cmd_help() {
printf '%s\n' "$HELP_TEXT"
}
cmd_create() {
local public=0
while [ $# -gt 0 ]; do
case "$1" in
-p) public=1; shift ;;
--) shift; break ;;
-*) echo "gist_sync: unknown flag '$1'" >&2; exit 2 ;;
*) break ;;
esac
done
[ $# -eq 1 ] || { echo "usage: gist_sync.sh create [-p] <file>" >&2; exit 2; }
local file=$1
[ -r "$file" ] || { echo "gist_sync: cannot read '$file'" >&2; exit 1; }
local existing
existing=$(extract_gist_url "$file")
if [ -n "$existing" ]; then
echo "gist_sync: file already linked to $existing" >&2
echo "gist_sync: use 'up' to push local changes, 'down' to pull" >&2
exit 1
fi
local abs
abs=$(abs_path "$file")
validate_path "$abs"
# Validate comment style BEFORE creating remote gist (so we don't leak gists
# for files we couldn't stamp anyway).
detect_comment "$file" >/dev/null
local create_args=(-d "$(basename -- "$file")")
[ "$public" -eq 1 ] && create_args+=(-p)
local url
if ! url=$(gh_retry_capture gist create "${create_args[@]}" -- "$file"); then
echo "gist_sync: gh gist create failed" >&2
exit 1
fi
url=$(printf '%s\n' "$url" | awk 'NF{u=$0} END{print u}')
case "$url" in
https://gist.github.com/*) ;;
*) echo "gist_sync: did not get a gist URL from gh, got: $url" >&2; exit 1 ;;
esac
local before
before=$(mktmp)
cp -- "$file" "$before"
insert_url_comment "$file" "$url"
local gist_id
gist_id=$(gist_id_from_url "$url")
# Stamp-push MUST succeed or local + gist diverge and a later `sync` would
# silently restore the unstamped version.
if ! gh_retry_capture gist edit "$gist_id" -- "$file" >/dev/null; then
echo "gist_sync: gh gist edit $gist_id failed after stamping; gist '$url' has unstamped content, local has stamp" >&2
echo "gist_sync: rerun once network recovers: gist_sync.sh up '$file'" >&2
exit 1
fi
cache_upsert "$abs" "$(sha_file "$file")"
printf 'created: %s\n' "$url"
print_diff "before/$file" "$before" "after/$file" "$file"
}
# do_up <file> <force:0|1>
# Returns 0 on success, non-zero on failure (does NOT exit so callers like
# cmd_sync can keep iterating).
do_up() {
local file=$1 force=${2:-0}
local url gist_id abs
url=$(extract_gist_url "$file")
[ -n "$url" ] || { echo "gist_sync: no gist URL in '$file' (run 'create' first)" >&2; return 1; }
gist_id=$(gist_id_from_url "$url") || return 1
abs=$(abs_path "$file")
validate_path "$abs"
local gist_tmp
gist_tmp=$(mktmp)
if ! gh_retry_to_file "$gist_tmp" gist view "$gist_id" -f "$(basename -- "$file")" -r; then
echo "gist_sync: gh gist view $gist_id failed (or file '$(basename -- "$file")' not in gist)" >&2
return 1
fi
local local_hash gist_hash cached_hash
local_hash=$(sha_file "$file")
gist_hash=$(sha_file "$gist_tmp")
cached_hash=$(cache_get_hash "$abs")
if [ "$local_hash" = "$gist_hash" ]; then
cache_upsert "$abs" "$local_hash"
printf 'unchanged: %s (local == gist)\n' "$url"
return 0
fi
# Safety: gist diverged from cached baseline means remote has changes the
# local copy never saw. Force-flag required to clobber.
if [ -n "$cached_hash" ] && [ "$gist_hash" != "$cached_hash" ] && [ "$force" != "1" ]; then
echo "gist_sync: refuse 'up' — gist $gist_id differs from cached baseline (remote changed)" >&2
echo "gist_sync: run 'sync' for 3-way handling, 'down -f' to discard local, or 'up -f' to overwrite gist" >&2
return 1
fi
print_diff "gist/$gist_id" "$gist_tmp" "local/$file" "$file"
if ! gh_retry_capture gist edit "$gist_id" -- "$file" >/dev/null; then
echo "gist_sync: gh gist edit $gist_id failed" >&2
return 1
fi
cache_upsert "$abs" "$local_hash"
printf 'up: %s\n' "$url"
}
# do_down <file> <force:0|1>
do_down() {
local file=$1 force=${2:-0}
local url gist_id abs
url=$(extract_gist_url "$file")
[ -n "$url" ] || { echo "gist_sync: no gist URL in '$file' (run 'create' first)" >&2; return 1; }
gist_id=$(gist_id_from_url "$url") || return 1
abs=$(abs_path "$file")
validate_path "$abs"
local gist_tmp
gist_tmp=$(mktmp)
if ! gh_retry_to_file "$gist_tmp" gist view "$gist_id" -f "$(basename -- "$file")" -r; then
echo "gist_sync: gh gist view $gist_id failed (or file '$(basename -- "$file")' not in gist)" >&2
return 1
fi
local local_hash gist_hash cached_hash
local_hash=$(sha_file "$file")
gist_hash=$(sha_file "$gist_tmp")
cached_hash=$(cache_get_hash "$abs")
if [ "$local_hash" = "$gist_hash" ]; then
cache_upsert "$abs" "$local_hash"
printf 'unchanged: %s (local == gist)\n' "$url"
return 0
fi
# Safety: local diverged from cached baseline means user has unpushed edits.
if [ -n "$cached_hash" ] && [ "$local_hash" != "$cached_hash" ] && [ "$force" != "1" ]; then
echo "gist_sync: refuse 'down' — local '$file' differs from cached baseline (you have unpushed edits)" >&2
echo "gist_sync: run 'sync' for 3-way handling, 'up -f' to push local, or 'down -f' to discard local" >&2
return 1
fi
print_diff "local/$file" "$file" "gist/$gist_id" "$gist_tmp"
# Always back up local before overwrite — undo safety net.
cp -- "$file" "$file.bak"
cp -- "$gist_tmp" "$file"
cache_upsert "$abs" "$gist_hash"
printf 'down: %s (backup at %s.bak)\n' "$url" "$file"
}
cmd_up() {
local force=0
while [ $# -gt 0 ]; do
case "$1" in
-f|--force) force=1; shift ;;
--) shift; break ;;
-*) echo "gist_sync: unknown flag '$1'" >&2; exit 2 ;;
*) break ;;
esac
done
[ $# -eq 1 ] || { echo "usage: gist_sync.sh up [-f] <file>" >&2; exit 2; }
[ -r "$1" ] || { echo "gist_sync: cannot read '$1'" >&2; exit 1; }
do_up "$1" "$force" || exit 1
}
cmd_down() {
local force=0
while [ $# -gt 0 ]; do
case "$1" in
-f|--force) force=1; shift ;;
--) shift; break ;;
-*) echo "gist_sync: unknown flag '$1'" >&2; exit 2 ;;
*) break ;;
esac
done
[ $# -eq 1 ] || { echo "usage: gist_sync.sh down [-f] <file>" >&2; exit 2; }
[ -e "$1" ] || { echo "gist_sync: '$1' does not exist (cannot determine gist; restore the file first)" >&2; exit 1; }
do_down "$1" "$force" || exit 1
}
cmd_sync() {
[ $# -eq 0 ] || { echo "usage: gist_sync.sh sync" >&2; exit 2; }
local up_n=0 down_n=0 unchanged_n=0 converged_n=0 skip_n=0 conflict_n=0 err_n=0
local rows=()
while IFS=$'\t' read -r path cached_hash; do
[ -n "$path" ] && rows+=("$path"$'\t'"$cached_hash")
done < <(cache_rows)
local entry path cached_hash
if [ "${#rows[@]}" -eq 0 ]; then
printf 'summary: up=0 down=0 unchanged=0 converged=0 skipped=0 conflicts=0 errors=0\n'
return 0
fi
for entry in "${rows[@]}"; do
path="${entry%%$'\t'*}"
cached_hash="${entry#*$'\t'}"
printf -- '--- %s ---\n' "$path"
if [ ! -e "$path" ]; then
printf 'skip: %s (missing)\n' "$path"
skip_n=$((skip_n + 1))
continue
fi
local url gist_id
url=$(extract_gist_url "$path")
if [ -z "$url" ]; then
printf 'error: %s (no gist url)\n' "$path"
err_n=$((err_n + 1))
continue
fi
if ! gist_id=$(gist_id_from_url "$url" 2>/dev/null); then
printf 'error: %s (malformed url %s)\n' "$path" "$url"
err_n=$((err_n + 1))
continue
fi
local local_hash gist_tmp gist_hash
local_hash=$(sha_file "$path")
gist_tmp=$(mktmp)
if ! gh_retry_to_file "$gist_tmp" gist view "$gist_id" -f "$(basename -- "$path")" -r; then
printf 'error: %s (gist %s unreachable or file missing)\n' "$path" "$gist_id"
err_n=$((err_n + 1))
continue
fi
gist_hash=$(sha_file "$gist_tmp")
if [ "$local_hash" = "$cached_hash" ] && [ "$gist_hash" = "$cached_hash" ]; then
printf 'unchanged: %s\n' "$path"
unchanged_n=$((unchanged_n + 1))
continue
fi
if [ "$local_hash" != "$cached_hash" ] && [ "$gist_hash" = "$cached_hash" ]; then
if do_up "$path" 1; then
up_n=$((up_n + 1))
else
err_n=$((err_n + 1))
fi
continue
fi
if [ "$local_hash" = "$cached_hash" ] && [ "$gist_hash" != "$cached_hash" ]; then
if do_down "$path" 1; then
down_n=$((down_n + 1))
else
err_n=$((err_n + 1))
fi
continue
fi
# Both differ from cache.
if [ "$local_hash" = "$gist_hash" ]; then
cache_upsert "$path" "$local_hash"
printf 'converged: %s\n' "$path"
converged_n=$((converged_n + 1))
continue
fi
# True conflict — print both diffs and skip.
local cache_tmp
cache_tmp=$(mktmp)
if [ -n "$cached_hash" ]; then
printf '(cached baseline hash: %s — content not retained)\n' "$cached_hash" >"$cache_tmp"
else
printf '(no cached baseline)\n' >"$cache_tmp"
fi
printf 'conflict: %s (local and gist both diverged from baseline)\n' "$path"
printf ' local diff:\n'
print_diff "cache" "$cache_tmp" "local" "$path"
printf ' gist diff:\n'
print_diff "cache" "$cache_tmp" "gist" "$gist_tmp"
conflict_n=$((conflict_n + 1))
done
printf 'summary: up=%d down=%d unchanged=%d converged=%d skipped=%d conflicts=%d errors=%d\n' \
"$up_n" "$down_n" "$unchanged_n" "$converged_n" "$skip_n" "$conflict_n" "$err_n"
if [ "$conflict_n" -gt 0 ] || [ "$err_n" -gt 0 ]; then
exit 1
fi
}
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
main() {
if [ $# -eq 0 ]; then
cmd_help
exit 0
fi
local cmd=$1
shift
# help / version need no setup
case "$cmd" in
help|--help|-h) cmd_help; exit 0 ;;
version|--version|-V) printf 'gist_sync.sh %s\n' "$GIST_SYNC_VERSION"; exit 0 ;;
esac
require_gh
ensure_cache
acquire_lock
case "$cmd" in
create) cmd_create "$@" ;;
up) cmd_up "$@" ;;
down) cmd_down "$@" ;;
sync) cmd_sync "$@" ;;
*)
echo "gist_sync: unknown command '$cmd'" >&2
echo "Try: gist_sync.sh help" >&2
exit 2
;;
esac
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment