Files
connor e6644d0616 Rebuild dotfiles as one branch with per-host layers
Replaces the previous repo, which had split into two histories that never
met (mainframe on a dead GitLab remote, the laptops on Gitea) with 146
dirty files across three machines and the NAS never enrolled at all.

Branch-per-machine is gone. One main, with host differences expressed as
small files under hosts/<hostname>/ rather than as branches, so there is
nothing to merge. The reconciled zsh layer reduces 15-33 line forks to
1-7 effective lines per host; distro differences (oh-my-zsh prefix,
syntax-highlighting path, fd vs fdfind) are probed in common/ instead.

Fresh history: the old one carried six plaintext credentials, 45 MB of
mail caches, browser caches and vendored binaries. 5,096 tracked files
and 144 MB become 462 files and 2.6 MB. The .gitignore is now an
allowlist, which is what keeps that true.

Root cause of the rot: ~/.local/bin was a symlink to scripts/ with GOPATH
inside it, so every go install wrote into version control (2.2 GB on the
work laptop). PATH now points at the repo instead of the reverse.

Also: Hyprland replaces sway and is sourced in two halves so $browser is
defined before use; singleton automations carry ConditionHost= alongside
host-layer-only placement; ddns moves from cron to a guarded timer;
package manifests and pkg-snapshot/pkg-restore replace the X11-era
install_scripts/; networkmanager-dmenu added to system76 (the binding
always existed, the package never did).
2026-09-14 14:24:37 -04:00

172 lines
6.7 KiB
Bash
Executable File

#!/bin/zsh
# Open today's note, reconciling it with the server around the edit.
#
# The server rolls the note forward at 00:01, so normally this fetches a file
# that already exists. Everything is arranged so that losing the network
# degrades to editing the local copy, never to losing an edit.
#
# Alongside the note, a pristine copy of the text last agreed with the server is
# kept, with its ETag. Without it there is no way to tell "I have unsaved edits"
# from "I am simply behind", and the second case gets misreported as the first
# every time the phone pushes.
emulate -L zsh
set -o errexit -o nounset -o pipefail
NOTES=${TODO_NOTES:-$HOME/docs/notes/todo}
STATE=${TODO_STATE:-${XDG_STATE_HOME:-$HOME/.local/state}/todo}
REMOTE=${TODO_REMOTE:-https://files.rcjohnstone.com/docs/notes/todo}
DAV_USER=${TODO_USER:-connor}
RBW_ENTRY=${TODO_RBW_ENTRY:-SFTPGo}
PRUNE=${TODO_PRUNE:-$HOME/.local/lib/todo/prune.py}
# zshenv exports EDITOR on every invocation, including this script's own, so
# `EDITOR=x todo` cannot work. TODO_EDITOR is the override that does.
EDIT=${TODO_EDITOR:-${EDITOR:-nvim}}
AGENT=todo-cli/2.0
today=$(date --iso-8601)
current=$NOTES/$today.md
base=$STATE/base/$today.md
base_etag=$STATE/base/$today.etag
mkdir -p $NOTES $STATE/base
work=$(mktemp -d); trap 'rm -rf $work' EXIT
note() { print -u2 -- "todo: $*" }
die() { print -u2 -- "todo: $*"; exit 1 }
# Password stays in a variable and reaches curl over stdin. Passing it as -u
# would put it in argv, where any other user's `ps` can read it.
dav_pass=""
if command -v rbw >/dev/null 2>&1; then
dav_pass=$(rbw get $RBW_ENTRY 2>/dev/null) || dav_pass=""
fi
dav() {
[[ -n $dav_pass ]] || return 64
printf 'user = "%s:%s"\n' $DAV_USER $dav_pass |
curl -sS --config - -A $AGENT --max-time 15 "$@"
}
etag_of() { grep -i '^etag:' $1 2>/dev/null | tr -d '\r' | awk '{print $2}' }
adopt() { # text-file etag
[[ $1 == $current ]] || cp $1 $current
cp $1 $base
print -r -- ${2:-} > $base_etag
}
set_aside() { # text-file -> prints the filename used
local name=$today.sync-conflict-$(date +%Y%m%d-%H%M%S)-${HOST:u}.md
cp $1 $NOTES/$name
dav -X PUT --data-binary @$1 -o /dev/null $REMOTE/$name >/dev/null 2>&1 || true
print -r -- $name
}
# ---------------------------------------------------------------- pull
remote_hash=""
if [[ -z $dav_pass ]]; then
note "no credential from rbw, working offline"
else
code=$(dav -o $work/theirs -D $work/h -w '%{http_code}' $REMOTE/$today.md) || code=000
case $code in
200)
their_etag=$(etag_of $work/h)
remote_hash=$(md5sum < $work/theirs)
if [[ ! -e $current ]]; then
adopt $work/theirs $their_etag
elif [[ ! -e $base ]]; then
# No record of a previous agreement, so nothing to merge from.
if cmp -s $current $work/theirs; then
adopt $work/theirs $their_etag
else
note "no sync history, yours saved as $(set_aside $current)"
adopt $work/theirs $their_etag
fi
elif [[ -e $base_etag && -n $their_etag && $their_etag == $(<$base_etag) ]]; then
: # server has not moved since the last agreement
elif git merge-file -p $current $base $work/theirs > $work/merged 2>/dev/null; then
# git rather than diff3. diff3 calls it a conflict even when
# both sides made the same change, and both devices ticking the
# same box is an ordinary thing to happen here.
cp $work/merged $current
if cmp -s $current $work/theirs; then
# Nothing local to contribute, so this is the new agreement.
adopt $work/theirs $their_etag
else
note "merged in changes from the server"
fi
else
# Both sides changed the same lines. The server's copy becomes
# the note and the local one goes beside it, which is the
# convention already in this directory.
note "conflict, yours saved as $(set_aside $current)"
adopt $work/theirs $their_etag
fi
;;
404)
# The 00:01 timer has not run, or the clock disagrees about the day.
# One create, never a poll loop: repeated 404s trip http-probing.
note "no $today.md on the server yet, creating it here"
;;
401)
# Never retry: repeated 401s from one IP trip http-generic-bf and
# earn a four hour ban that no whitelist covers.
die "credentials rejected. fix the $RBW_ENTRY entry, do not re-run blindly"
;;
000) note "server unreachable, working offline" ;;
*) note "server returned $code, working offline" ;;
esac
fi
# ------------------------------------------------------- local fallback
if [[ ! -e $current ]]; then
# Match the date glob exactly. A bare `ls | tail -1` can select a
# .sync-conflict- sibling, which is how divergent notes spread before.
previous=($NOTES/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].md(N))
if (( ${#previous} )); then
src=${previous[-1]}
if [[ -x $PRUNE ]]; then
$PRUNE $src > $work/new
else
note "prune not found at $PRUNE, carrying the note forward unpruned"
cp $src $work/new
fi
# Only line 1. A global replace would rewrite any date in the body, so
# "meeting moved to 2026-09-08" would silently become today.
sed -i "1s/.*/# $today/" $work/new
cp $work/new $current
note "created $today.md from ${src:t}"
else
printf '# %s\n' $today > $current
note "started a fresh $today.md"
fi
fi
# ---------------------------------------------------------------- edit
$EDIT $current
# ---------------------------------------------------------------- push
[[ $(md5sum < $current) == $remote_hash ]] && { note "in step with the server"; exit 0 }
[[ -n $dav_pass ]] || { note "no credential, leaving changes local"; exit 0 }
code=$(dav -X PUT --data-binary @$current -o /dev/null -w '%{http_code}' $REMOTE/$today.md) ||
code=000
case $code in
200|201|204)
# The ETag a PUT returns is not the file's own, so it is read back
# rather than believed. See FINDINGS.md.
dav -I -D $work/ph -o /dev/null $REMOTE/$today.md >/dev/null 2>&1 || true
adopt $current "$(etag_of $work/ph)"
note "pushed $today.md"
;;
401) die "credentials rejected on push. changes are safe locally" ;;
000) note "push failed, server unreachable. changes are safe locally" ;;
*) note "push returned $code. changes are safe locally" ;;
esac