summaryrefslogtreecommitdiff
path: root/bin/docsync
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-01 07:03:26 -0800
committerskylar <me@casper>2026-11-01 07:03:26 -0800
commit070a107873440d62b4519e6d91c2ba739d4b6fa2 (patch)
treeae4e1c3540cdd01b3289ed6f96ec3ea69507f73e /bin/docsync
Literate Doom Emacs configuration
config.org tangles to config.el, which loads private/config.el last when it exists. bin/ holds the scripts some sections drive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'bin/docsync')
-rwxr-xr-xbin/docsync314
1 files changed, 314 insertions, 0 deletions
diff --git a/bin/docsync b/bin/docsync
new file mode 100755
index 0000000..5f0c19c
--- /dev/null
+++ b/bin/docsync
@@ -0,0 +1,314 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/docsync"
+STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/docsync"
+INCLUDE_FILE="$CONFIG_DIR/include"
+EXCLUDE_FILE="$CONFIG_DIR/exclude"
+
+# The repository is reached over SFTP. $CONFIG_DIR/config sets SSH_HOST to a
+# Host in ~/.ssh/config that logs in without a prompt (a key, BatchMode-safe),
+# and may set REPO_PATH, the repository's path on that host. The password
+# file holds the repository's password, kept in a file so the timer never
+# waits on a prompt; restic's other keys (a paper recovery passphrase, say)
+# still open the repository as usual.
+SSH_HOST=
+REPO_PATH=repo
+# shellcheck source=/dev/null
+[[ -f "$CONFIG_DIR/config" ]] && . "$CONFIG_DIR/config"
+export RESTIC_REPOSITORY="sftp:$SSH_HOST:$REPO_PATH"
+export RESTIC_PASSWORD_FILE="$CONFIG_DIR/restic-password"
+
+# Every docsync snapshot carries this tag, and snapshots are grouped by host
+# and tag rather than by path list, so adding or dropping a path continues the
+# same history (and retention) instead of starting a new one.
+TAG=docsync
+GROUP_BY=host,tags
+
+# Retention for 'docsync forget': every hourly snapshot for two days, then one
+# a day for a month, one a week for a year, and one a month for good.
+KEEP=(--keep-hourly 48 --keep-daily 30 --keep-weekly 52 --keep-monthly unlimited)
+
+# How much of the stored data 'docsync check' downloads and verifies per run.
+CHECK_SUBSET=5%
+
+# Desktop notifications (only when run by a systemd timer): a failure while the
+# server is reachable is worth hearing about soon, being offline only once no
+# backup has landed for a day. At most one notification per window either way.
+ERROR_NOTIFY_EVERY=$((6 * 3600))
+OFFLINE_NOTIFY_AFTER=$((24 * 3600))
+
+usage() {
+ cat <<EOF
+Usage: docsync COMMAND
+
+ backup take a snapshot now (the hourly timer runs this)
+ forget thin out old snapshots and free their space (weekly timer)
+ check verify the repository and $CHECK_SUBSET of its data (monthly timer)
+ status when the last backup landed, and the latest snapshots
+ paths the paths and exclude patterns a backup would use
+ manifest rewrite the list of files in the latest snapshot (backup does this)
+ restic ARGS any restic command against the repository, e.g.
+ docsync restic snapshots
+ docsync restic mount ~/mnt/docsync
+
+Setup, all in $CONFIG_DIR:
+ config shell assignments: SSH_HOST=<a Host in ~/.ssh/config>,
+ and optionally REPO_PATH=<path on it> (default: repo)
+ restic-password the repository's password, mode 600
+ include one path per line, relative to ~ unless it starts with /
+ exclude restic exclude patterns; a leading ~/ means your home
+Blank lines and lines starting with # are ignored in include and exclude.
+Create the repository once with: docsync restic init
+The systemd units beside this script run backup hourly, forget weekly and
+check monthly; they expect it at ~/.local/bin/docsync.
+
+State for other tools (Emacs) is in $STATE_DIR:
+ last-success epoch seconds of the last backup that saved a snapshot
+ last-result "EPOCH STATUS MESSAGE", STATUS one of ok, warning, offline, error
+ missing include paths that didn't exist at the last backup
+ manifest every file in the latest snapshot as TYPE SIZE MTIME PATH,
+ tab-separated (jq @tsv escaping), after a "# snapshot ID TIME"
+ line; Emacs compares it with the disk to mark what's changed
+EOF
+}
+
+die() { echo "docsync: $*" >&2; exit 1; }
+
+now() { date +%s; }
+
+# A list file's entries, without comments, blank lines or surrounding space.
+list_lines() {
+ [[ -f "$1" ]] || return 0
+ sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^#/d' -e '/^$/d' "$1"
+}
+
+include_paths() {
+ local p
+ list_lines "$INCLUDE_FILE" | while IFS= read -r p; do
+ p="${p#\~/}"
+ [[ "$p" == /* ]] || p="$HOME/$p"
+ [[ "$p" == / ]] || p="${p%/}"
+ printf '%s\n' "$p"
+ done
+}
+
+exclude_patterns() {
+ local p
+ list_lines "$EXCLUDE_FILE" | while IFS= read -r p; do
+ [[ "$p" == "~/"* ]] && p="$HOME/${p#\~/}"
+ printf '%s\n' "$p"
+ done
+}
+
+# ---------------------------------------------------------------- state
+
+put_state() { # NAME LINE...
+ if (( $# > 1 )); then printf '%s\n' "${@:2}"; fi > "$STATE_DIR/$1.tmp"
+ mv "$STATE_DIR/$1.tmp" "$STATE_DIR/$1"
+}
+
+get_state() { # NAME DEFAULT
+ if [[ -s "$STATE_DIR/$1" ]]; then head -n1 "$STATE_DIR/$1"; else printf '%s\n' "$2"; fi
+}
+
+notify() { # WINDOW URGENCY MESSAGE
+ [[ -n "${INVOCATION_ID:-}" ]] || return 0
+ local last; last=$(get_state notified 0)
+ (( $(now) - last >= $1 )) || return 0
+ notify-send -a docsync -u "$2" docsync "$3" 2>/dev/null || true
+ put_state notified "$(now)"
+}
+
+# A run that dies mid-operation (the server dropping off during a prune, say)
+# leaves its lock behind, and restic never clears one by itself: every later
+# run would wait out --retry-lock and fail. Without --remove-all, unlock
+# removes only stale locks, ones whose process has exited or that haven't been
+# refreshed for 30 minutes (a running restic refreshes its lock every 5).
+unlock_stale() {
+ restic unlock || true
+}
+
+# A cheap "is the server reachable" probe. With an SFTP-only account, whose
+# forced command answers any login with an SFTP server that exits at EOF, this
+# still returns as soon as the connection is up.
+reachable() {
+ ssh -o BatchMode=yes "$SSH_HOST" true </dev/null >/dev/null 2>&1
+}
+
+# Not a failure (a laptop's network comes and goes, and the next hourly run
+# tries again), so only worth a notification once no backup has landed for a day.
+backup_offline() {
+ put_state last-result "$(now) offline can't reach $SSH_HOST"
+ if (( $(now) - $(get_state last-success 0) >= OFFLINE_NOTIFY_AFTER )); then
+ notify "$OFFLINE_NOTIFY_AFTER" normal "No backup for over a day: can't reach $SSH_HOST."
+ fi
+ echo "docsync: can't reach $SSH_HOST, skipping this backup" >&2
+ exit 0
+}
+
+ago() { # EPOCH
+ local s=$(( $(now) - $1 ))
+ if (( s < 90 )); then echo "just now"
+ elif (( s < 5400 )); then echo "$(( s / 60 )) min ago"
+ elif (( s < 172800 )); then echo "$(( s / 3600 )) h ago"
+ else echo "$(( s / 86400 )) days ago"
+ fi
+}
+
+# ---------------------------------------------------------------- commands
+
+# Every file in the latest snapshot with its size and modification time, so
+# Emacs can tell what has changed since without asking the repository. A
+# failure keeps the old manifest: Emacs then shows more as changed than has,
+# never less.
+cmd_manifest() {
+ mkdir -p "$STATE_DIR"
+ local out="$STATE_DIR/manifest"
+ if restic ls --json --tag "$TAG" latest 2>/dev/null | jq -r '
+ if .struct_type == "snapshot" then "# snapshot \(.id) \(.time)"
+ elif .struct_type == "node" then [.type, (.size // 0), .mtime, .path] | @tsv
+ else empty end' > "$out.tmp"; then
+ mv "$out.tmp" "$out"
+ else
+ rm -f "$out.tmp"
+ echo "docsync: couldn't list the latest snapshot; the manifest is stale" >&2
+ return 1
+ fi
+}
+
+cmd_backup() {
+ mkdir -p "$STATE_DIR"
+ exec 9>"$STATE_DIR/lock"
+ flock -n 9 || { echo "docsync: a backup is already running" >&2; exit 0; }
+
+ [[ -f "$RESTIC_PASSWORD_FILE" ]] ||
+ die "no password file; put the repository's password in $RESTIC_PASSWORD_FILE (mode 600)"
+ [[ -f "$INCLUDE_FILE" ]] || die "nothing to back up: $INCLUDE_FILE is missing"
+
+ # Global, not local: the EXIT trap runs after this function has returned.
+ tmp=$(mktemp -d)
+ trap 'rm -rf "$tmp"' EXIT
+
+ local -a present=() missing=()
+ local p
+ while IFS= read -r p; do
+ if [[ -e "$p" || -L "$p" ]]; then present+=("$p"); else missing+=("$p"); fi
+ done < <(include_paths)
+ put_state missing "${missing[@]}"
+ for p in "${missing[@]}"; do echo "docsync: listed but missing: $p" >&2; done
+
+ if (( ${#present[@]} == 0 )); then
+ put_state last-result "$(now) error none of the listed paths exist"
+ notify "$ERROR_NOTIFY_EVERY" critical "Backup failed: none of the listed paths exist."
+ die "none of the paths in $INCLUDE_FILE exist"
+ fi
+ printf '%s\n' "${present[@]}" > "$tmp/include"
+ exclude_patterns > "$tmp/exclude"
+
+ reachable || backup_offline
+ unlock_stale
+ local rc=0
+ restic backup \
+ --files-from-verbatim "$tmp/include" \
+ --exclude-file "$tmp/exclude" \
+ --exclude-caches \
+ --tag "$TAG" --group-by "$GROUP_BY" \
+ --skip-if-unchanged \
+ --retry-lock 1h \
+ 2>"$tmp/err" || rc=$?
+ # Echoed by bash rather than cat: journald files a line under its unit by
+ # looking up the writing process, and cat has exited by then, so restic's
+ # error would be missing from journalctl --user -u docsync-backup.
+ [[ ! -s "$tmp/err" ]] || printf '%s\n' "$(<"$tmp/err")" >&2
+ local err; err=$(grep -v '^[[:space:]]*$' "$tmp/err" | tail -n1 || true)
+
+ case $rc in
+ 0|3) put_state last-success "$(now)"
+ rm -f "$STATE_DIR/notified"
+ cmd_manifest || true ;;
+ esac
+ case $rc in
+ 0) if (( ${#missing[@]} )); then
+ put_state last-result "$(now) warning ${#missing[@]} listed path(s) missing"
+ notify "$ERROR_NOTIFY_EVERY" normal "Backed up, but ${#missing[@]} listed path(s) no longer exist."
+ else
+ put_state last-result "$(now) ok"
+ fi ;;
+ 3) put_state last-result "$(now) warning some files couldn't be read: $err"
+ notify "$ERROR_NOTIFY_EVERY" normal "Backed up, but some files couldn't be read." ;;
+ *) # Losing the server partway through is being offline, not an error.
+ reachable || backup_offline
+ put_state last-result "$(now) error ${err:-restic exited with $rc}"
+ notify "$ERROR_NOTIFY_EVERY" critical "Backup failed: ${err:-restic exited with $rc}"
+ exit "$rc" ;;
+ esac
+}
+
+# forget and check: on failure under a timer, say so, unless the server dropped
+# off; the next scheduled run clears the stale lock that leaves and starts over.
+maintenance() { # WHAT RESTIC-ARGS...
+ local what=$1; shift
+ unlock_stale
+ if ! restic "$@" --retry-lock 1h; then
+ [[ -n "${INVOCATION_ID:-}" ]] && reachable &&
+ notify-send -a docsync -u critical docsync "docsync $what failed; see journalctl --user -u docsync-$what" 2>/dev/null
+ exit 1
+ fi
+}
+
+cmd_forget() {
+ maintenance forget forget --tag "$TAG" --group-by "$GROUP_BY" "${KEEP[@]}" --prune
+}
+
+cmd_check() {
+ maintenance check check --read-data-subset "$CHECK_SUBSET"
+}
+
+cmd_status() {
+ local last result
+ last=$(get_state last-success "")
+ result=$(get_state last-result "")
+ if [[ -n "$last" ]]; then
+ echo "Last backup: $(date -d "@$last" '+%F %R') ($(ago "$last"))"
+ else
+ echo "Last backup: never"
+ fi
+ if [[ -n "$result" ]]; then
+ read -r when status msg <<<"$result"
+ echo "Last run: $(date -d "@$when" '+%F %R') $status${msg:+: $msg}"
+ fi
+ if [[ -s "$STATE_DIR/missing" ]]; then
+ echo "Missing:"; sed 's/^/ /' "$STATE_DIR/missing"
+ fi
+ echo
+ restic snapshots --tag "$TAG" --group-by "$GROUP_BY" --latest 5 --compact ||
+ echo "(couldn't list snapshots; is $SSH_HOST reachable?)"
+}
+
+cmd_paths() {
+ local p
+ echo "Include:"
+ include_paths | while IFS= read -r p; do
+ if [[ -e "$p" || -L "$p" ]]; then echo " $p"; else echo " $p (missing)"; fi
+ done
+ echo "Exclude:"
+ exclude_patterns | sed 's/^/ /'
+}
+
+case "${1:-}" in
+ backup|forget|check|status|manifest|restic)
+ [[ -n "$SSH_HOST" ]] || die "set SSH_HOST in $CONFIG_DIR/config" ;;
+esac
+
+case "${1:-}" in
+ backup) cmd_backup ;;
+ forget) cmd_forget ;;
+ check) cmd_check ;;
+ status) cmd_status ;;
+ paths) cmd_paths ;;
+ manifest) cmd_manifest ;;
+ restic) shift; exec restic "$@" ;;
+ -h|--help|help|"") usage ;;
+ *) usage >&2; exit 2 ;;
+esac