#!/bin/sh
# exercise the lord state machine end to end
# shellcheck disable=SC3043 # local: supported by every /bin/sh this targets
set -u
here=$(CDPATH='' cd "$(dirname "$0")" && pwd)
T=${1:-${TMPDIR:-/tmp}/lord-test-$$}
rm -rf "$T"
mkdir -p "$T/bin"
ln -s "$here/lord" "$T/bin/lord"
export PATH="$T/bin:$PATH"
export LORD_DIR="$T/store"
export LORD_CONFIG="$T/config"
export LORD_STATE="$T/state"
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@x GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@x
# offline stand-in for the provider APIs: "<url> <provider> <host> <id>"
# lines in $IDS, the last one wins; an unlisted URL has no id, like an
# unreachable API
IDS="$T/ids"
: > "$IDS"
cat > "$T/bin/idcmd" <<EOF
#!/bin/sh
awk -v u="\$1" '\$1 == u { l = \$2 " " \$3 " " \$4 } END { if (l != "") print l }' "$IDS"
EOF
chmod +x "$T/bin/idcmd"
export LORD_ID_CMD="$T/bin/idcmd"
pass=0 failn=0
ok() { pass=$((pass+1)); printf 'ok %s\n' "$*"; }
bad() { failn=$((failn+1)); printf 'FAIL %s\n' "$*"; }
check() { # check <desc> <cmd...>
local d="$1"; shift
if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi
}
STORE="$T/store/github.com/acme/app"
# --- repo1: adoption ---------------------------------------------------
R1="$T/repo1"
git init -q -b main "$R1"
git -C "$R1" remote add origin git@github.com:acme/app.git
printf 'readme\n' > "$R1/README.md"
git -C "$R1" add README.md && git -C "$R1" commit -qm init
printf 'SECRET=1\n' > "$R1/.env"
mkdir -p "$R1/.claude"
printf '{}\n' > "$R1/.claude/settings.json"
printf '.env\n.claude/\nghost.txt\n# comment\n\n' > "$R1/.lord"
out=$(cd "$R1" && lord 2>&1); st=$?
printf -- '--- first run ---\n%s\n' "$out"
check "first run exits 0" test "$st" = 0
check ".lord adopted as symlink" test -L "$R1/.lord"
check ".lord in store" test -f "$STORE/.lord"
check ".env adopted as symlink" test -L "$R1/.env"
check ".env content in store" grep -q SECRET=1 "$STORE/.env"
check ".claude adopted (trailing slash stripped)" test -L "$R1/.claude"
check ".claude dir in store" test -f "$STORE/.claude/settings.json"
check "exclude has /.lord" grep -qxF '/.lord' "$R1/.git/info/exclude"
check "exclude has /.env" grep -qxF '/.env' "$R1/.git/info/exclude"
check "exclude has /ghost.txt" grep -qxF '/ghost.txt' "$R1/.git/info/exclude"
check "pre-commit hook installed" grep -qxF 'LORD_HOOK=1 lord' "$R1/.git/hooks/pre-commit"
check "post-checkout hook installed" test -x "$R1/.git/hooks/post-checkout"
check "post-merge hook installed" test -x "$R1/.git/hooks/post-merge"
check "git status clean" test -z "$(git -C "$R1" status --porcelain)"
out=$(cd "$R1" && lord 2>&1); st=$?
check "second run silent" test -z "$out"
check "second run exits 0" test "$st" = 0
# an unchanged hook or exclude is left alone, so lord still runs where
# they can't be written, such as a sandbox that protects .git/hooks
ro="$R1/.git/hooks/pre-commit $R1/.git/hooks/post-checkout $R1/.git/hooks/post-merge $R1/.git/info/exclude"
# shellcheck disable=SC2086
chmod a-w $ro
out=$(cd "$R1" && lord 2>&1); st=$?
check "run with read-only hooks and exclude exits 0" test "$st" = 0
check "run with read-only hooks and exclude silent" test -z "$out"
# shellcheck disable=SC2086
chmod u+w $ro
# ghost.txt appears later -> already ignored, adopted on next run
printf 'boo\n' > "$R1/ghost.txt"
check "ghost.txt ignored before adoption" test -z "$(git -C "$R1" status --porcelain)"
out=$(cd "$R1" && lord 2>&1)
check "ghost adopted on appearance" test -L "$R1/ghost.txt"
check "ghost run printed only adopt line" test "$out" = "adopt ghost.txt"
# edit manifest through the symlink -> lands in store
printf 'nested/dir/token\n' >> "$R1/.lord"
check "manifest edit lands in store" grep -q nested/dir/token "$STORE/.lord"
out=$(cd "$R1" && lord 2>&1) # refresh exclude so the new entry is ignored
mkdir -p "$R1/nested/dir" && printf 'tok\n' > "$R1/nested/dir/token"
out=$(cd "$R1" && lord 2>&1)
check "nested entry adopted" test -L "$R1/nested/dir/token"
check "nested entry in store" test -f "$STORE/nested/dir/token"
# run from a subdirectory
out=$(cd "$R1/nested" && lord 2>&1); st=$?
check "run from subdir silent+0" test "$st" = 0 -a -z "$out"
# pre-commit guard: force-add a listed file
git -C "$R1" add -f .env 2>/dev/null
if git -C "$R1" commit -qm leak 2>"$T/precommit.err"; then
bad "pre-commit blocks staged listed path"
git -C "$R1" reset -q HEAD~1 2>/dev/null
else
ok "pre-commit blocks staged listed path"
fi
check "pre-commit error is E1" grep -q '^E1: tracked in git' "$T/precommit.err"
git -C "$R1" reset -q -- .env
# pre-commit converge adopts a listed file that appeared since the last run
printf 'auto.txt\n' >> "$R1/.lord"
(cd "$R1" && lord >/dev/null 2>&1) # refresh exclude for the new entry
printf 'a\n' > "$R1/auto.txt"
printf 'more\n' >> "$R1/README.md"
git -C "$R1" add README.md
git -C "$R1" commit -qm bump >/dev/null 2>&1
check "pre-commit adopts new listed file" test -L "$R1/auto.txt"
check "pre-commit adoption reaches store" test -f "$STORE/auto.txt"
# orphan reporting
printf 'stray\n' > "$STORE/stray.conf"
out=$(cd "$R1" && lord 2>&1); st=$?
check "orphan reported" printf '%s' "$out"
case $out in *"orphan in store, not in manifest: stray.conf"*) ok "orphan message";; *) bad "orphan message: $out";; esac
check "orphan does not fail run" test "$st" = 0
check "orphan not restored to worktree" test ! -e "$R1/stray.conf"
rm "$STORE/stray.conf"
# --- repo2: fresh clone ------------------------------------------------
R2="$T/repo2"
git clone -q "$R1" "$R2"
git -C "$R2" remote set-url origin https://github.com/acme/app.git
out=$(cd "$R2" && lord 2>&1); st=$?
printf -- '--- fresh clone run ---\n%s\n' "$out"
check "clone run exits 0" test "$st" = 0
check "clone .lord linked" test -L "$R2/.lord"
check "clone .env linked" test -L "$R2/.env"
check "clone .claude linked" test -L "$R2/.claude"
check "clone nested entry linked" test -L "$R2/nested/dir/token"
check "clone reads secret through link" grep -q SECRET=1 "$R2/.env"
check "clone status clean" test -z "$(git -C "$R2" status --porcelain)"
# conflict: real file differs from store
rm "$R2/.env"
printf 'SECRET=other\n' > "$R2/.env"
out=$(cd "$R2" && lord 2>&1); st=$?
check "conflict exits 17" test "$st" = 17
case $out in *"conflict: .env in both worktree and store: resolve manually"*) ok "conflict message";; *) bad "conflict message: $out";; esac
check "conflict leaves worktree file" grep -q SECRET=other "$R2/.env"
check "conflict leaves store file" grep -q SECRET=1 "$STORE/.env"
# identical copy where the link was: relinked
printf 'SECRET=1\n' > "$R2/.env"
out=$(cd "$R2" && lord -n 2>&1); st=$?
check "dry identical copy prints relink" test "$out" = "relink .env"
check "dry identical copy left real" test -f "$R2/.env" -a ! -L "$R2/.env"
out=$(cd "$R2" && lord 2>&1); st=$?
check "identical copy exits 0" test "$st" = 0
check "identical copy prints relink" test "$out" = "relink .env"
check "identical copy relinked" test "$(readlink "$R2/.env")" = "$STORE/.env"
check "identical copy keeps store file" grep -q SECRET=1 "$STORE/.env"
# identical manifest copy: relinked too
cp "$STORE/.lord" "$T/manifest.copy"
rm "$R2/.lord"; cp "$T/manifest.copy" "$R2/.lord"
out=$(cd "$R2" && lord 2>&1); st=$?
check "identical manifest exits 0" test "$st" = 0
check "identical manifest prints relink" test "$out" = "relink .lord"
check "identical manifest relinked" test "$(readlink "$R2/.lord")" = "$STORE/.lord"
# differing manifest copy still refuses
rm "$R2/.lord"; { cat "$T/manifest.copy"; printf '# local\n'; } > "$R2/.lord"
out=$(cd "$R2" && lord 2>&1); st=$?
check "differing manifest exits 17" test "$st" = 17
check "differing manifest left real" test -f "$R2/.lord" -a ! -L "$R2/.lord"
rm "$R2/.lord"; (cd "$R2" && lord >/dev/null 2>&1)
check "manifest relinked after removing copy" test -L "$R2/.lord"
# foreign symlink
rm "$R2/.env"; ln -s /etc/hostname "$R2/.env"
out=$(cd "$R2" && lord 2>&1); st=$?
check "foreign symlink exits 17" test "$st" = 17
rm "$R2/.env"; (cd "$R2" && lord >/dev/null 2>&1)
# --- repo3: tracked listed path ----------------------------------------
R3="$T/repo3"
git init -q -b main "$R3"
git -C "$R3" remote add origin git@github.com:acme/other.git
printf 'x\n' > "$R3/config.ini"
git -C "$R3" add config.ini && git -C "$R3" commit -qm c
printf 'config.ini\n' > "$R3/.lord"
out=$(cd "$R3" && lord 2>&1); st=$?
check "tracked entry exits 1" test "$st" = 1
case $out in *"E1: tracked in git, refusing to manage: config.ini"*) ok "tracked message";; *) bad "tracked message: $out";; esac
check "tracked file untouched" test -f "$R3/config.ini" -a ! -L "$R3/config.ini"
check "manifest still adopted" test -L "$R3/.lord"
# invalid entries
printf '/abs/path\n../escape\n' >> "$R3/.lord"
out=$(cd "$R3" && lord 2>&1); st=$?
check "invalid entry exits 22" test "$st" = 22
case $out in *"E22: invalid manifest entry: /abs/path"*) ok "invalid abs message";; *) bad "invalid: $out";; esac
# tracked manifest identical to the store copy: refused, never relinked
R3b="$T/repo3b"
git init -q -b main "$R3b"
git -C "$R3b" remote add origin git@github.com:acme/tracked.git
mkdir -p "$T/store/github.com/acme/tracked"
printf '.env\n' > "$T/store/github.com/acme/tracked/.lord"
cp "$T/store/github.com/acme/tracked/.lord" "$R3b/.lord"
git -C "$R3b" add .lord && git -C "$R3b" commit -qm c
out=$(cd "$R3b" && lord 2>&1); st=$?
check "tracked identical manifest exits 1" test "$st" = 1
case $out in *"E1: tracked in git, refusing to manage: .lord"*) ok "tracked manifest message";; *) bad "tracked manifest message: $out";; esac
check "tracked identical manifest left real" test -f "$R3b/.lord" -a ! -L "$R3b/.lord"
# --- no-origin repo -----------------------------------------------------
R4="$T/repo4"
git init -q -b main "$R4"
out=$(cd "$R4" && lord 2>&1); st=$?
check "no origin: E2" test "$st" = 2
case $out in E2:*origin*) ok "no-origin message";; *) bad "no-origin message: $out";; esac
case $out in *"hint: declare one now"*"git remote add origin"*) ok "no-origin hint";; *) bad "no-origin hint: $out";; esac
# --- bootstrap: no manifest anywhere -> init empty one -------------------
R8="$T/repo8"
git init -q -b main "$R8"
git -C "$R8" remote add origin git@github.com:acme/blank.git
out=$(cd "$R8" && lord 2>&1); st=$?
check "bootstrap exits 0" test "$st" = 0
check "bootstrap prints init" test "$out" = "init .lord
init .git/lord"
check "bootstrap .lord is symlink" test -L "$R8/.lord"
check "bootstrap manifest in store" test -f "$T/store/github.com/acme/blank/.lord"
check "bootstrap exclude has /.lord" grep -qxF '/.lord' "$R8/.git/info/exclude"
check "bootstrap hooks installed" test -x "$R8/.git/hooks/pre-commit"
out=$(cd "$R8" && lord 2>&1); st=$?
check "bootstrap second run silent+0" test "$st" = 0 -a -z "$out"
# --- not a repo ---------------------------------------------------------
mkdir -p "$T/plain"
out=$(cd "$T/plain" && lord 2>&1); st=$?
check "not a repo: E22" test "$st" = 22
# --- existing hook preserved -------------------------------------------
R5="$T/repo5"
git init -q -b main "$R5"
git -C "$R5" remote add origin git@github.com:acme/hooky.git
printf '#!/bin/sh\necho husky-stuff\n' > "$R5/.git/hooks/pre-commit"
chmod +x "$R5/.git/hooks/pre-commit"
printf '.env\n' > "$R5/.lord"
(cd "$R5" && lord >/dev/null 2>&1)
check "existing hook content preserved" grep -q husky-stuff "$R5/.git/hooks/pre-commit"
check "lord block appended to hook" grep -qxF 'LORD_HOOK=1 lord' "$R5/.git/hooks/pre-commit"
(cd "$R5" && lord >/dev/null 2>&1)
n=$(grep -cxF 'LORD_HOOK=1 lord' "$R5/.git/hooks/pre-commit")
check "hook block not duplicated" test "$n" = 1
# post-checkout materializing a listed file -> conflict surfaced
R6="$T/repo6"
git init -q -b main "$R6"
git -C "$R6" remote add origin git@github.com:acme/branchy.git
printf 'base\n' > "$R6/base.txt"
git -C "$R6" add -A && git -C "$R6" commit -qm base
git -C "$R6" checkout -qb feature
printf 'DBPASS=x\n' > "$R6/creds"
git -C "$R6" add creds && git -C "$R6" commit -qm creds
git -C "$R6" checkout -q main
printf 'creds\n' > "$R6/.lord"
(cd "$R6" && lord >/dev/null 2>&1)
out=$(cd "$R6" && git checkout feature 2>&1); st=$?
case $out in *"E1: tracked in git, refusing to manage: creds"*) ok "post-checkout surfaces tracked conflict";; *) bad "post-checkout: $out";; esac
# --- usage ---------------------------------------------------------------
out=$(lord --help); st=$?
check "--help exits 0" test "$st" = 0
case $out in "usage: lord [-n] [dir]"*) ok "--help prints usage";; *) bad "--help output: $out";; esac
out=$(lord -x 2>&1); st=$?
check "unknown flag exits 22" test "$st" = 22
out=$(lord -V); st=$?
check "-V exits 0" test "$st" = 0
case $out in "lord "[0-9]*) ok "-V prints version";; *) bad "-V output: $out";; esac
check "--version matches -V" test "$(lord --version)" = "$out"
# --- xdg fallback: LORD_DIR unset -> XDG_DATA_HOME/lord -----------------
R7="$T/repo7"
git init -q -b main "$R7"
git -C "$R7" remote add origin git@github.com:acme/xdg.git
printf '.env\n' > "$R7/.lord"
printf 'x\n' > "$R7/.env"
(cd "$R7" && env -u LORD_DIR XDG_DATA_HOME="$T/xdg" lord >/dev/null 2>&1)
check "XDG_DATA_HOME fallback store" test -f "$T/xdg/lord/github.com/acme/xdg/.env"
check "LORD_DIR wins over XDG_DATA_HOME" test -d "$T/store/github.com/acme/app"
# --- unlisting restores the file -----------------------------------------
grep -v '^ghost.txt$' "$R1/.lord" > "$T/manifest.tmp"
cat "$T/manifest.tmp" > "$R1/.lord" # write through the symlink
out=$(cd "$R1" && lord 2>&1); st=$?
check "restore exits 0" test "$st" = 0
check "restore prints" test "$out" = "restore ghost.txt"
check "restored as real file" test -f "$R1/ghost.txt" -a ! -L "$R1/ghost.txt"
check "restored content intact" grep -q boo "$R1/ghost.txt"
check "store entry gone after restore" test ! -e "$STORE/ghost.txt"
check "exclude line gone after restore" sh -c "! grep -qxF '/ghost.txt' '$R1/.git/info/exclude'"
check "restored file visible to git" sh -c "git -C '$R1' status --porcelain | grep -q ghost.txt"
rm "$R1/ghost.txt"
# nested restore prunes emptied store dirs
grep -v '^nested/dir/token$' "$R1/.lord" > "$T/manifest.tmp"
cat "$T/manifest.tmp" > "$R1/.lord"
out=$(cd "$R1" && lord 2>&1)
check "nested restore prints" test "$out" = "restore nested/dir/token"
check "nested restored as real file" test -f "$R1/nested/dir/token" -a ! -L "$R1/nested/dir/token"
check "emptied store dirs pruned" test ! -d "$STORE/nested"
# unlisted store entry without a worktree symlink stays an orphan
printf 'keepme\n' > "$STORE/lonely.txt"
out=$(cd "$R1" && lord 2>&1); st=$?
case $out in *"orphan in store, not in manifest: lonely.txt"*) ok "orphan without link not restored";; *) bad "orphan without link: $out";; esac
check "orphan stays in store" test -f "$STORE/lonely.txt"
check "orphan not placed in worktree" test ! -e "$R1/lonely.txt"
rm "$STORE/lonely.txt"
# --- skeleton manifest ---------------------------------------------------
mkdir -p "$T/config"
printf '.env\n.claude\n' > "$T/config/skel"
R9="$T/repo9"
git init -q -b main "$R9"
git -C "$R9" remote add origin git@github.com:acme/skel.git
out=$(cd "$R9" && lord 2>&1); st=$?
check "skel bootstrap exits 0" test "$st" = 0
check "skel bootstrap prints source" test "$out" = "init .lord (from skel)
init .git/lord"
check "manifest copied from skel" cmp -s "$T/config/skel" "$T/store/github.com/acme/skel/.lord"
check "skel entry excluded before existing" grep -qxF '/.env' "$R9/.git/info/exclude"
check "nonexistent skel entry not linked" test ! -e "$R9/.env" -a ! -L "$R9/.env"
printf 'S=1\n' > "$R9/.env"
out=$(cd "$R9" && lord 2>&1)
check "skel entry adopted on appearance" test -L "$R9/.env"
check "skel entry reaches store" test -f "$T/store/github.com/acme/skel/.env"
check "manifest diverges freely" sh -c "printf 'extra\n' >> '$R9/.lord' && ! grep -q extra '$T/config/skel'"
# --- dry run --------------------------------------------------------------
R10="$T/repo10"
git init -q -b main "$R10"
git -C "$R10" remote add origin git@github.com:acme/dry.git
printf 'DB=x\n' > "$R10/.env"
printf '.env\n' > "$R10/.lord"
out=$(cd "$R10" && lord -n 2>&1); st=$?
check "dry run exits 0" test "$st" = 0
check "dry prints plan" test "$out" = "adopt .lord
adopt .env
init .git/lord"
check "dry leaves manifest real" test -f "$R10/.lord" -a ! -L "$R10/.lord"
check "dry leaves file real" test -f "$R10/.env" -a ! -L "$R10/.env"
check "dry creates no store" test ! -d "$T/store/github.com/acme/dry"
check "dry installs no hooks" test ! -e "$R10/.git/hooks/pre-commit"
(cd "$R10" && lord >/dev/null 2>&1)
out=$(cd "$R10" && lord -n 2>&1); st=$?
check "dry on converged repo silent+0" test "$st" = 0 -a -z "$out"
# dry init reads the skel without creating anything
R11="$T/repo11"
git init -q -b main "$R11"
git -C "$R11" remote add origin git@github.com:acme/dryinit.git
printf 'S=1\n' > "$R11/.env"
out=$(cd "$R11" && lord -n 2>&1)
check "dry init plans skel adoption" test "$out" = "init .lord (from skel)
adopt .env
init .git/lord"
check "dry init creates nothing" test ! -e "$R11/.lord" -a ! -d "$T/store/github.com/acme/dryinit"
# --- eject ----------------------------------------------------------------
R12="$T/repo12"
git init -q -b main "$R12"
git -C "$R12" remote add origin git@github.com:acme/ejectme.git
printf 'S=1\n' > "$R12/.env"
mkdir -p "$R12/n/d" && printf 'tok\n' > "$R12/n/d/tok"
printf '.env\nn/d/tok\n' > "$R12/.lord"
(cd "$R12" && lord >/dev/null 2>&1)
check "eject setup converged" test -L "$R12/.env"
out=$(cd "$R12" && lord eject 2>&1); st=$?
check "eject exits 0" test "$st" = 0
check "eject prints restores, manifest last" test "$out" = "restore .git/lord
restore .env
restore n/d/tok
restore .lord"
check "ejected files real" test -f "$R12/.env" -a ! -L "$R12/.env" -a -f "$R12/n/d/tok" -a ! -L "$R12/n/d/tok"
check "ejected manifest real" test -f "$R12/.lord" -a ! -L "$R12/.lord"
check "eject removes store dir" test ! -d "$T/store/github.com/acme/ejectme"
check "eject strips exclude block" sh -c "! grep -q lord-managed '$R12/.git/info/exclude'"
check "eject removes stub hooks" test ! -e "$R12/.git/hooks/pre-commit" -a ! -e "$R12/.git/hooks/post-merge"
check "ejected files visible to git" sh -c "git -C '$R12' status --porcelain | grep -q .env"
out=$(cd "$R12" && lord eject 2>&1); st=$?
check "second eject silent+0" test "$st" = 0 -a -z "$out"
# eject preserves foreign hook content (repo5 has a husky-style pre-commit)
(cd "$R5" && lord eject >/dev/null 2>&1)
check "eject keeps foreign hook lines" grep -q husky-stuff "$R5/.git/hooks/pre-commit"
check "eject strips lord line from hook" sh -c "! grep -qxF 'LORD_HOOK=1 lord' '$R5/.git/hooks/pre-commit'"
# --- dangling link sweep ---------------------------------------------------
# R2 still links ghost.txt and nested/dir/token, both since restored in R1
out=$(cd "$R2" && lord 2>&1)
case $out in *"unlisted symlink into lord store: ghost.txt"*) ok "sweep warns dangling ghost";; *) bad "sweep ghost: $out";; esac
case $out in *"unlisted symlink into lord store: nested/dir/token"*) ok "sweep warns dangling nested";; *) bad "sweep nested: $out";; esac
ln -s "$STORE/.env" "$R2/stray"
out=$(cd "$R2" && lord 2>&1)
case $out in *"unlisted symlink into lord store: stray"*) ok "sweep warns manual stray";; *) bad "sweep stray: $out";; esac
check "sweep does not remove links" test -L "$R2/stray"
rm "$R2/stray" "$R2/ghost.txt" "$R2/nested/dir/token"
# --- identity: .lord-id --------------------------------------------------
S="$T/store/github.com/acme"
R13="$T/repo13"
git init -q -b main "$R13"
git -C "$R13" remote add origin git@github.com:acme/old.git
printf 'x\n' > "$R13/f" && git -C "$R13" add f && git -C "$R13" commit -qm c
root13=$(git -C "$R13" rev-list --max-parents=0 HEAD)
printf 'S=1\n' > "$R13/.env"
printf '.env\n' > "$R13/.lord"
printf 'git@github.com:acme/old.git github github.com 42\n' >> "$IDS"
out=$(cd "$R13" && lord 2>&1); st=$?
check "id setup exits 0" test "$st" = 0
check ".lord-id records url" grep -qx 'url git@github.com:acme/old.git' "$S/old/.lord-id"
check ".lord-id records root" grep -qx "root $root13" "$S/old/.lord-id"
check ".lord-id records provider id" grep -qx 'id github github.com 42' "$S/old/.lord-id"
check ".lord-id not linked into worktree" test ! -e "$R13/.lord-id" -a ! -L "$R13/.lord-id"
out=$(cd "$R13" && lord 2>&1); st=$?
check "id second run silent+0" test "$st" = 0 -a -z "$out"
out=$(cd "$R13" && lord id 2>&1); st=$?
check "id exits 0" test "$st" = 0
case $out in *"id github github.com 42"*"recorded id github github.com 42"*) ok "id prints live and recorded id";; *) bad "id output: $out";; esac
# --- rename in place: set-url, then lord moves the store -----------------
printf 'git@github.com:acme/new.git github github.com 42\n' >> "$IDS"
git -C "$R13" remote set-url origin git@github.com:acme/new.git
out=$(cd "$R13" && lord -n 2>&1); st=$?
check "dry rename exits 0" test "$st" = 0
check "dry rename prints only the move" test "$out" = "move github.com/acme/old -> github.com/acme/new"
check "dry rename moves nothing" test -d "$S/old" -a ! -L "$S/old" -a ! -e "$S/new"
out=$(cd "$R13" && lord 2>&1); st=$?
check "rename exits 0" test "$st" = 0
check "rename prints move and relinks" test "$out" = "move github.com/acme/old -> github.com/acme/new
relink .lord
relink .env
relink .git/lord"
check "store moved to new key" test -f "$S/new/.env" -a ! -L "$S/new"
check "old key forwards to new" test "$(readlink "$S/old")" = "$S/new"
check "links point at new key" test "$(readlink "$R13/.env")" = "$S/new/.env"
check "file readable after rename" grep -q S=1 "$R13/.env"
check ".lord-id url follows rename" grep -qx 'url git@github.com:acme/new.git' "$S/new/.lord-id"
check ".lord-id keeps id" grep -qx 'id github github.com 42' "$S/new/.lord-id"
out=$(cd "$R13" && lord 2>&1); st=$?
check "after rename silent+0" test "$st" = 0 -a -z "$out"
# a clone still on the old URL keeps working through the forward
R14="$T/repo14"
git clone -q "$R13" "$R14"
git -C "$R14" remote set-url origin git@github.com:acme/old.git
out=$(cd "$R14" && lord 2>&1); st=$?
check "old-URL clone exits 0" test "$st" = 0
check "old-URL clone links, never moves" test "$out" = "link .lord
link .env
link .git/lord"
check "old-URL clone reads the file" grep -q S=1 "$R14/.env"
check "store stays at new key" test -d "$S/new" -a ! -L "$S/new" -a -L "$S/old"
check "old-URL clone leaves the record alone" grep -qx 'url git@github.com:acme/new.git' "$S/new/.lord-id"
out=$(cd "$R13" && lord 2>&1); st=$?
check "new-URL clone unaffected, silent+0" test "$st" = 0 -a -z "$out"
# a fresh clone under a newer name finds the store by id alone
printf 'git@github.com:acme/newer.git github github.com 42\n' >> "$IDS"
R15="$T/repo15"
git clone -q "$R13" "$R15"
git -C "$R15" remote set-url origin git@github.com:acme/newer.git
out=$(cd "$R15" && lord 2>&1); st=$?
check "fresh clone after rename exits 0" test "$st" = 0
check "fresh clone moves by id and links" test "$out" = "move github.com/acme/new -> github.com/acme/newer
link .lord
link .env
link .git/lord"
check "fresh clone reads the file" grep -q S=1 "$R15/.env"
out=$(cd "$R13" && lord 2>&1); st=$?
check "earlier clone follows the chain" test "$st" = 0 -a -z "$out"
check "earlier clone still reads the file" grep -q S=1 "$R13/.env"
# --- a fork or copy: other id, same history -> never takes the store -----
R16="$T/repo16"
git clone -q "$R13" "$R16"
git -C "$R16" remote set-url origin git@github.com:acme/newer.git
(cd "$R16" && lord >/dev/null 2>&1)
printf 'git@github.com:someone/fork.git github github.com 99\n' >> "$IDS"
git -C "$R16" remote set-url origin git@github.com:someone/fork.git
out=$(cd "$R16" && lord 2>&1); st=$?
check "fork refuses with 17" test "$st" = 17
case $out in *"move "*) bad "fork moved the store: $out";; *) ok "fork does not move";; esac
check "fork leaves upstream store" test -f "$S/newer/.env"
check "no store under fork key" test ! -e "$T/store/github.com/someone/fork"
git -C "$R16" remote set-url origin git@github.com:acme/newer.git
# --- offline: no id, move only on the checkout's own link ---------------
R17="$T/repo17"
git init -q -b main "$R17"
git -C "$R17" remote add origin git@gitea.example:me/off.git
printf 'y\n' > "$R17/g" && git -C "$R17" add g && git -C "$R17" commit -qm c
printf 'T=1\n' > "$R17/.env"
printf '.env\n' > "$R17/.lord"
(cd "$R17" && lord >/dev/null 2>&1)
check "offline store records id none" grep -qx 'id none' "$T/store/gitea.example/me/off/.lord-id"
git -C "$R17" remote set-url origin git@gitea.example:me/on.git
out=$(cd "$R17" && lord 2>&1); st=$?
check "offline rename exits 0" test "$st" = 0
check "offline rename moves by link" test "$out" = "move gitea.example/me/off -> gitea.example/me/on
relink .lord
relink .env
relink .git/lord"
check "offline rename reads the file" grep -q T=1 "$R17/.env"
# a fresh clone with no id and no link has nothing to go on
R18="$T/repo18"
git clone -q "$R17" "$R18"
git -C "$R18" remote set-url origin git@gitea.example:me/elsewhere.git
out=$(cd "$R18" && lord 2>&1); st=$?
check "unprovable clone exits 0" test "$st" = 0
case $out in "init .lord"*) ok "unprovable clone gets a new store";; *) bad "unprovable clone: $out";; esac
check "unprovable clone leaves the store" test -f "$T/store/gitea.example/me/on/.env"
# recorded id, but the lookup fails now: no move, a warning
git -C "$R15" remote set-url origin git@github.com:acme/offline.git
out=$(cd "$R15" && lord 2>&1); st=$?
case $out in *"cannot look up the repo id"*"unverified"*) ok "unverified move warns";; *) bad "unverified warn: $out";; esac
check "unverified move exits 17" test "$st" = 17
check "unverified move keeps the store" test -f "$S/newer/.env" -a ! -e "$S/offline"
git -C "$R15" remote set-url origin git@github.com:acme/newer.git
# --- two stores with the same id: refuse, never guess ------------------
mkdir -p "$S/dup" && printf '.env\n' > "$S/dup/.lord"
printf 'url x\nid github github.com 42\n' > "$S/dup/.lord-id"
R19="$T/repo19"
git clone -q "$R13" "$R19"
printf 'git@github.com:acme/third.git github github.com 42\n' >> "$IDS"
git -C "$R19" remote set-url origin git@github.com:acme/third.git
out=$(cd "$R19" && lord 2>&1); st=$?
check "ambiguous id exits 17" test "$st" = 17
case $out in *"several stores carry this repo's id"*) ok "ambiguous id message";; *) bad "ambiguous: $out";; esac
check "ambiguous id moves nothing" test -f "$S/newer/.env" -a -d "$S/dup" -a ! -e "$S/third"
rm -r "$S/dup"
# --- a store moved by hand: dangling links are re-pointed ---------------
mv "$T/store/gitea.example/me/on" "$T/store/gitea.example/me/hand"
git -C "$R17" remote set-url origin git@gitea.example:me/hand.git
out=$(cd "$R17" && lord 2>&1); st=$?
check "manual move exits 0" test "$st" = 0
check "manual move relinks" test "$out" = "relink .lord
relink .env
relink .git/lord"
check "manual move reads the file" grep -q T=1 "$R17/.env"
# --- a new repo under a forwarded name gets its own store ----------------
printf 'git@github.com:acme/old.git github github.com 77\n' >> "$IDS"
R20="$T/repo20"
git init -q -b main "$R20"
git -C "$R20" remote add origin git@github.com:acme/old.git
printf 'other\n' > "$R20/h" && git -C "$R20" add h && git -C "$R20" commit -qm other
out=$(cd "$R20" && lord 2>&1); st=$?
check "new repo on old name exits 0" test "$st" = 0
case $out in *"forwarded to"*"different repo; dropping the forward"*"init .lord"*) ok "stale forward dropped";; *) bad "stale forward: $out";; esac
check "old name is a real store again" test -d "$S/old" -a ! -L "$S/old"
check "renamed repo's store untouched" test -f "$S/newer/.env"
# --- reserved name ---------------------------------------------------------
printf '.lord-id\n' >> "$R13/.lord"
out=$(cd "$R13" && lord 2>&1); st=$?
check "listing .lord-id exits 22" test "$st" = 22
case $out in *"reserved for lord's own metadata: .lord-id"*) ok "reserved name message";; *) bad "reserved: $out";; esac
grep -vxF '.lord-id' "$R13/.lord" > "$T/manifest.tmp"
cat "$T/manifest.tmp" > "$R13/.lord"
# --- eject removes the record with the store ---------------------------
R21="$T/repo21"
git init -q -b main "$R21"
git -C "$R21" remote add origin git@github.com:acme/gone.git
printf 'G=1\n' > "$R21/.env" && printf '.env\n' > "$R21/.lord"
(cd "$R21" && lord >/dev/null 2>&1)
check "record written before eject" test -f "$S/gone/.lord-id"
out=$(cd "$R21" && lord eject 2>&1); st=$?
check "eject with record exits 0" test "$st" = 0
check "eject removes store and record" test ! -e "$S/gone"
# --- registry, -l and -a -------------------------------------------------
# reg_has <state dir> <path>: the registry lists the path
reg_has() {
awk -v p="$2" '{ sub(/^[^ ]+ /, "") } $0 == p { f = 1 } END { exit !f }' "$1/checkouts"
}
# a real run registers, a dry run does not
R30="$T/repo30"
git init -q -b main "$R30"
git -C "$R30" remote add origin git@github.com:acme/reg.git
printf '.env\n' > "$R30/.lord"
printf 'R=1\n' > "$R30/.env"
(cd "$R30" && lord -n >/dev/null 2>&1)
check "dry run does not register" test ! -f "$T/state/checkouts" -o -z "$(grep -F "$R30" "$T/state/checkouts")"
(cd "$R30" && lord >/dev/null 2>&1)
check "real run registers" reg_has "$T/state" "$R30"
check "registry line is time then path" grep -Eq "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8}Z $R30\$" "$T/state/checkouts"
(cd "$R30" && lord >/dev/null 2>&1)
check "rerun keeps one entry" test "$(grep -cF "$R30" "$T/state/checkouts")" = 1
# a state dir that cannot be written never fails or clutters a run
: > "$T/notadir"
out=$(cd "$R30" && LORD_STATE="$T/notadir/x" lord 2>&1); st=$?
check "unwritable state dir exits 0" test "$st" = 0
check "unwritable state dir is silent" test -z "$out"
# a path with a space survives the registry
R31="$T/repo 31"
git init -q -b main "$R31"
git -C "$R31" remote add origin git@github.com:acme/spaced.git
printf '.env\n' > "$R31/.lord"
(cd "$R31" && lord >/dev/null 2>&1)
check "path with a space registers" reg_has "$T/state" "$R31"
# lstate <path>: the state -l reports for a checkout, read from the
# tab separated form that a pipe gets
lstate() {
lord list 2>/dev/null | awk -F '\t' -v p="$1" '$3 == p { print $1 }'
}
# -l: ok, then drift, then dead
lord list >/dev/null 2>&1; st=$?
check "list exits 0" test "$st" = 0
check "list shows an ok checkout" test "$(lstate "$R30")" = ok
check "list time field is ISO" sh -c "lord list | awk -F '\t' -v p='$R30' '\$3 == p' | grep -Eq '^ok [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8}Z '"
rm -f "$R30/.env"; printf 'R=1\n' > "$R30/.env" # identical copy: relink pending
check "list shows drift" test "$(lstate "$R30")" = drift
check "list changed nothing" test ! -L "$R30/.env"
rm -rf "$R31"
check "list marks a deleted checkout dead" test "$(lstate "$R31")" = dead
check "list leaves a dead entry in place" reg_has "$T/state" "$R31"
(cd "$R30" && lord >/dev/null 2>&1)
# linked worktrees register apart from the main checkout
R32="$T/repo32"
git init -q -b main "$R32"
git -C "$R32" remote add origin git@github.com:acme/trees.git
printf 'w\n' > "$R32/w" && git -C "$R32" add w && git -C "$R32" commit -qm w
printf '.env\n' > "$R32/.lord"
(cd "$R32" && lord >/dev/null 2>&1)
git -C "$R32" worktree add -q "$T/wt32" -b b32 2>/dev/null
check "main checkout registered" reg_has "$T/state" "$R32"
check "linked worktree registered apart" reg_has "$T/state" "$(git -C "$T/wt32" rev-parse --show-toplevel)"
# a warning alone is no drift
printf 'stray\n' > "$T/store/github.com/acme/trees/stray.conf"
check "list counts a warning as ok" test "$(lstate "$R32")" = ok
rm "$T/store/github.com/acme/trees/stray.conf"
# on a terminal -l prints ages and aligned columns; script supplies the pty
if command -v script >/dev/null 2>&1; then
printf '2020-01-01T00:00:00Z %s\n' "$R32" >> "$T/state/checkouts"
tty_out=$(HOME="$T" script -qec "lord list" /dev/null 2>&1 | tr -d '\r')
case $tty_out in
*"ok now ~/repo30"*|*"drift now ~/repo30"*) ok "list on a terminal shows a fresh age and ~";;
*) bad "list terminal age: $tty_out";;
esac
case $tty_out in *" "[0-9]"y "*" ~/repo32"*) ok "list on a terminal shows years";; *) bad "list terminal years: $tty_out";; esac
case $tty_out in *T[0-9][0-9]:*) bad "list terminal leaks ISO: $tty_out";; *) ok "list on a terminal hides the ISO time";; esac
case $tty_out in *"$T/repo"*) bad "list terminal leaves HOME unabbreviated: $tty_out";; *) ok "list on a terminal abbreviates HOME";; esac
# registry order puts repo32 above repo30; a terminal sorts by path
check "list on a terminal sorts by path" test "$(printf '%s\n' "$tty_out" | awk '/~\/repo30/ { a = NR } /~\/repo32/ && !b { b = NR } END { print (a < b) }')" = 1
check "list in a pipe keeps registry order" test "$(lord list | awk -F '\t' -v a="$R30" -v b="$R32" '$3 == a { x = NR } $3 == b && !y { y = NR } END { print (y < x) }')" = 1
(cd "$R32" && lord >/dev/null 2>&1)
fi
# eject deregisters
(cd "$R30" && lord eject >/dev/null 2>&1)
check "eject deregisters" test -z "$(grep -F "$R30" "$T/state/checkouts")"
# grammar rules: each of these is refused with E22 and a message that says why
refuse() { # refuse <desc> <message substring> <lord args...>
local d="$1" want="$2"
shift 2
out=$(lord "$@" 2>&1); st=$?
check "$d exits 22" test "$st" = 22
case $out in *"$want"*) ok "$d says why";; *) bad "$d message: $out";; esac
}
refuse "list with a dir" "takes no directory" list "$R32"
refuse "all with a dir" "takes no directory" all "$R32"
refuse "prune with a dir" "takes no directory" prune "$R32"
refuse "list with -n" "takes no -n" list -n
refuse "id with -n" "takes no -n" id -n
refuse "command after its flag" "goes first" -n all
refuse "old -a" "-a is now: lord all" -a
refuse "old -e" "-e is now: lord eject" -e
refuse "old -i" "-i is now: lord id" -i
refuse "old -l" "-l is now: lord list" -l
refuse "unknown word" "unknown command or directory: frob" frob
refuse "two directories" "too many arguments" "$R32" "$R32"
# a directory named like a command is reached with ./ or after --
mkdir "$T/cmds"
git init -q -b main "$T/cmds/list"
git -C "$T/cmds/list" remote add origin git@github.com:acme/listed.git
printf '.env\n' > "$T/cmds/list/.lord"
out=$(cd "$T/cmds" && lord ./list 2>&1); st=$?
check "./list names the directory" test "$st" = 0
check "./list converged it" test -L "$T/cmds/list/.lord"
out=$(cd "$T/cmds" && lord -n -- list 2>&1); st=$?
check "-- ends the options" test "$st" = 0
mkdir "$T/cmds/-n"
git init -q -b main "$T/cmds/-n"
git -C "$T/cmds/-n" remote add origin git@github.com:acme/dashn.git
out=$(cd "$T/cmds" && lord -- -n 2>&1); st=$?
check "-- lets a dir start with a dash" test "$st" = 0
out=$(lord -h | head -1)
check "-h prints usage" test "$out" = "usage: lord [-n] [dir]"
out=$(lord list --help | head -1)
check "--help works after a command" test "$out" = "usage: lord [-n] [dir]"
out=$(lord list -h | head -1)
check "-h works after a command" test "$out" = "usage: lord [-n] [dir]"
# all and prune on a registry of their own
S2="$T/state2"
mkdir -p "$S2"
R40="$T/repo40"
git init -q -b main "$R40"
git -C "$R40" remote add origin git@github.com:acme/all1.git
printf '.env\nlater.txt\n' > "$R40/.lord"
printf 'A=1\n' > "$R40/.env"
(cd "$R40" && LORD_STATE="$S2" lord >/dev/null 2>&1)
R41="$T/repo41"
git init -q -b main "$R41"
git -C "$R41" remote add origin git@github.com:acme/all2.git
printf 'x\n' > "$R41/config.ini"
git -C "$R41" add config.ini && git -C "$R41" commit -qm c
printf 'config.ini\n' > "$R41/.lord"
(cd "$R41" && LORD_STATE="$S2" lord >/dev/null 2>&1)
check "failing checkout still registers" reg_has "$S2" "$R41"
R42="$T/repo42"
git init -q -b main "$R42"
git -C "$R42" remote add origin git@github.com:acme/all3.git
printf '2020-01-01T00:00:00Z %s\n' "$R42" >> "$S2/checkouts"
printf 'later\n' > "$R40/later.txt"
cp "$S2/checkouts" "$T/reg.before"
out=$(LORD_STATE="$S2" lord all -n 2>&1); st=$?
check "all -n exits with the failing checkout's code" test "$st" = 1
case $out in *"$R40"*"adopt later.txt"*) ok "all -n reports pending work";; *) bad "all -n report: $out";; esac
case $out in *"skip $R42"*) ok "all -n skips the non-lord path";; *) bad "all -n skip: $out";; esac
check "all -n leaves the file real" test -f "$R40/later.txt" -a ! -L "$R40/later.txt"
check "all -n leaves the registry alone" cmp -s "$S2/checkouts" "$T/reg.before"
out=$(LORD_STATE="$S2" lord all 2>&1); st=$?
check "all exits with the failing checkout's code" test "$st" = 1
case $out in *"$R41"*"tracked in git"*) ok "all reports the failure";; *) bad "all failure: $out";; esac
check "all still converged the healthy checkout" test -L "$R40/later.txt"
check "all never initializes a non-lord repo" test ! -e "$R42/.lord" -a ! -d "$T/store/github.com/acme/all3"
case $out in *"skip $R42"*) ok "all skips the non-lord path";; *) bad "all skip: $out";; esac
check "all leaves the registry alone" reg_has "$S2" "$R42"
cp "$S2/checkouts" "$T/reg.before"
out=$(LORD_STATE="$S2" lord prune -n 2>&1); st=$?
check "prune -n exits 0" test "$st" = 0
check "prune -n reports the drop" test "$out" = "drop $R42"
check "prune -n leaves the registry alone" cmp -s "$S2/checkouts" "$T/reg.before"
out=$(LORD_STATE="$S2" lord prune 2>&1); st=$?
check "prune exits 0" test "$st" = 0
check "prune reports the drop" test "$out" = "drop $R42"
check "prune drops the non-lord path" test -z "$(grep -F "$R42" "$S2/checkouts")"
check "prune keeps the live paths" reg_has "$S2" "$R40"
check "prune keeps the failing path" reg_has "$S2" "$R41"
out=$(LORD_STATE="$S2" lord prune 2>&1)
check "prune with nothing to drop is silent" test -z "$out"
# a registry holding only converged checkouts: -a is silent
S3="$T/state3"
(cd "$R40" && LORD_STATE="$S3" lord >/dev/null 2>&1)
out=$(LORD_STATE="$S3" lord all 2>&1); st=$?
check "all with nothing to do exits 0" test "$st" = 0
check "all with nothing to do is silent" test -z "$out"
out=$(LORD_STATE="$T/state-none" lord all 2>&1); st=$?
check "all without a registry exits 0 silent" test "$st" = 0 -a -z "$out"
out=$(LORD_STATE="$T/state-none" lord list 2>&1); st=$?
check "list without a registry exits 0 silent" test "$st" = 0 -a -z "$out"
# --- stash and notes ----------------------------------------------------
NS="$T/store/github.com/acme/notes"
N1="$T/notes1"
git init -q -b main "$N1"
git -C "$N1" remote add origin git@github.com:acme/notes.git
printf 'a\n' > "$N1/a" && git -C "$N1" add a && git -C "$N1" commit -qm one
mkdir -p "$N1/.git/lord" && printf 'mine\n' > "$N1/.git/lord/keep"
out=$(cd "$N1" && lord 2>&1); st=$?
check "stash run exits 0" test "$st" = 0
check "a real stash is adopted" test "$out" = "init .lord (from skel)
adopt .git/lord"
check "stash is a link into the store" test "$(readlink "$N1/.git/lord")" = "$NS/.lord-stash"
check "adopted stash keeps its files" grep -qx mine "$NS/.lord-stash/keep"
check "pre-push guard installed" grep -qF 'refs/notes/*)' "$N1/.git/hooks/pre-push"
check "post-rewrite hook installed" grep -qxF 'LORD_HOOK=1 lord' "$N1/.git/hooks/post-rewrite"
check "status clean with a stash" test -z "$(git -C "$N1" status --porcelain)"
printf '.lord-stash\n' >> "$N1/.lord"
out=$(cd "$N1" && lord 2>&1); st=$?
check "manifest listing the stash exits 22" test "$st" = 22
case $out in *"reserved for lord's own metadata: .lord-stash"*) ok "stash entry refused";; *) bad "stash entry: $out";; esac
sed -i.bak '/^\.lord-stash$/d' "$NS/.lord" && rm -f "$NS/.lord.bak"
mkdir -p "$N1/.lord-stash" && printf 'x\n' > "$N1/.lord-stash/x"
printf '.lord-stash/x\n' >> "$N1/.lord"
out=$(cd "$N1" && lord 2>&1); st=$?
check "manifest entry inside the stash exits 22" test "$st" = 22
case $out in *"reserved for lord's own metadata: .lord-stash/x"*) ok "entry inside the stash refused";; *) bad "inside stash: $out";; esac
check "entry inside the stash stays out of it" test ! -e "$NS/.lord-stash/x"
sed -i.bak '/^\.lord-stash\/x$/d' "$NS/.lord" && rm -f "$NS/.lord.bak"
rm -rf "$N1/.lord-stash"
git -C "$N1" notes add -m 'first note'
out=$(cd "$N1" && lord -n 2>&1)
check "dry run plans the save" test "$out" = "save refs/notes/commits"
check "dry run writes no bundle" test ! -e "$NS/.lord-stash/notes.bundle"
out=$(cd "$N1" && lord 2>&1); st=$?
check "notes save exits 0" test "$st" = 0
check "notes save prints the ref" test "$out" = "save refs/notes/commits"
check "bundle holds the ref" test "$(git -C "$N1" bundle list-heads "$NS/.lord-stash/notes.bundle" | cut -d' ' -f2)" = refs/notes/commits
out=$(cd "$N1" && lord 2>&1)
check "saved notes run silent" test -z "$out"
N2="$T/notes2"
git clone -q "$N1" "$N2"
git -C "$N2" remote set-url origin git@github.com:acme/notes.git
out=$(cd "$N2" && lord 2>&1); st=$?
check "clone with notes exits 0" test "$st" = 0
check "clone links the stash and loads notes" test "$out" = "link .lord
link .git/lord
load refs/notes/commits"
check "clone reads the note" test "$(git -C "$N2" notes show HEAD 2>/dev/null)" = "first note"
BARE="$T/notes-bare.git"
git init -q --bare "$BARE"
if git -C "$N2" push -q "$BARE" refs/notes/commits 2>"$T/push.err"; then
bad "pre-push refuses notes"
else
ok "pre-push refuses notes"
fi
check "pre-push names the ref" grep -q '^E1: refusing to push refs/notes/commits' "$T/push.err"
check "refused notes never reach the remote" test -z "$(git -C "$BARE" for-each-ref refs/notes/)"
check "pre-push lets branches through" git -C "$N2" push -q "$BARE" main
check "pre-push refuses notes under another name" sh -c "! git -C '$N2' push -q '$BARE' refs/notes/commits:refs/heads/leak 2>/dev/null"
check "pre-push refuses a notes commit by id" sh -c "! git -C '$N2' push -q '$BARE' \$(git -C '$N2' rev-parse refs/notes/commits):refs/heads/leak 2>/dev/null"
check "renamed notes never reach the remote" test -z "$(git -C "$BARE" for-each-ref refs/heads/leak)"
git -C "$N2" push -q --no-verify "$BARE" refs/notes/commits
check "pre-push lets a notes deletion through" git -C "$N2" push -q "$BARE" :refs/notes/commits
check "the deleted notes are gone" test -z "$(git -C "$BARE" for-each-ref refs/notes/)"
# a foreign pre-push hook that reads the refs, as git lfs does, still gets
# them, and the guard goes first in it
N4="$T/notes4"
git clone -q "$N1" "$N4"
git -C "$N4" remote set-url origin git@github.com:acme/notes.git
printf '#!/bin/sh\ncat > "%s"\n# lord-managed begin\nold guard\n# lord-managed end\n' "$T/lfs.in" > "$N4/.git/hooks/pre-push"
chmod +x "$N4/.git/hooks/pre-push"
(cd "$N4" && lord >/dev/null 2>&1)
check "guard moves first in a foreign pre-push hook" test "$(sed -n 2p "$N4/.git/hooks/pre-push")" = '# lord-managed begin'
check "old guard block replaced" sh -c "! grep -qx 'old guard' '$N4/.git/hooks/pre-push'"
check "foreign hook keeps its own code" grep -qF 'lfs.in' "$N4/.git/hooks/pre-push"
check "guard first refuses notes" sh -c "! git -C '$N4' push -q '$BARE' refs/notes/commits:refs/heads/leak 2>/dev/null"
git -C "$N4" push -q "$BARE" main:refs/heads/lfs
check "foreign hook reads the refs after the guard" grep -q ' refs/heads/lfs ' "$T/lfs.in"
# a pre-push hook in another language gets no sh block, loses one an older
# lord appended, and its repo's notes stay out of the stash
N5="$T/notes5"
git clone -q "$N1" "$N5"
git -C "$N5" remote set-url origin git@github.com:acme/notes.git
printf '#!/usr/bin/env python3\nimport sys\n# lord-managed begin\nwhile read -r _; do :; done\n# lord-managed end\n' > "$N5/.git/hooks/pre-push"
out=$(cd "$N5" && lord 2>&1); st=$?
check "python pre-push run exits 0" test "$st" = 0
case $out in *"hooks/pre-push is not a shell script: lord block left out"*) ok "python pre-push warned";; *) bad "python pre-push: $out";; esac
case $out in *"push guard is left out: notes left alone"*) ok "python pre-push leaves notes alone";; *) bad "python notes: $out";; esac
check "python pre-push loses the sh block" sh -c "! grep -q 'lord-managed' '$N5/.git/hooks/pre-push'"
check "python pre-push keeps its code" grep -qx 'import sys' "$N5/.git/hooks/pre-push"
check "unguarded python clone gets no notes" test -z "$(git -C "$N5" for-each-ref refs/notes/)"
git -C "$N2" notes add -f -m 'second note'
out=$(cd "$N2" && lord 2>&1)
check "newer notes here are saved" test "$out" = "save refs/notes/commits"
out=$(cd "$N1" && lord 2>&1)
check "newer notes in the stash are loaded" test "$out" = "load refs/notes/commits"
check "load fast-forwards the ref" test "$(git -C "$N1" notes show HEAD)" = "second note"
git -C "$N1" update-ref -d refs/notes/commits
out=$(cd "$N1" && lord 2>&1)
check "a deleted notes ref comes back" test "$out" = "load refs/notes/commits"
printf 'b\n' > "$N1/b" && git -C "$N1" add b && git -C "$N1" commit -qm two >/dev/null 2>&1
git -C "$N1" notes add -m 'note on two'
git -C "$N2" notes add -f -m 'other note'
git -C "$N2" notes --ref=extra add -m 'extra note'
(cd "$N1" && lord >/dev/null 2>&1)
cp "$NS/.lord-stash/notes.bundle" "$T/bundle.before"
out=$(cd "$N2" && lord 2>&1); st=$?
check "diverged notes exit 17" test "$st" = 17
case $out in *"conflict: refs/notes/commits differs here and in the stash"*) ok "diverged notes named";; *) bad "diverged: $out";; esac
case $out in *save*) bad "diverged run claims a save: $out";; *) ok "diverged run claims no save";; esac
check "diverged notes leave the bundle" cmp -s "$NS/.lord-stash/notes.bundle" "$T/bundle.before"
check "diverged notes leave no lock" test ! -e "$NS/.lord-stash/notes.lock"
# the hooks only warn, so diverged notes never block a commit
out=$(git -C "$N2" commit -q --allow-empty -m hooked 2>&1); st=$?
check "diverged notes let a commit through" test "$st" = 0
case $out in *"warn: conflict: refs/notes/commits differs"*) ok "commit hook warns about the notes";; *) bad "commit hook: $out";; esac
git -C "$N2" reset -q --hard HEAD~1
# the merge the conflict names leaves nothing behind for lord to save
hint=${out#*stash: }
hint=${hint%%
*}
(cd "$N2" && eval "$hint") >/dev/null 2>&1
check "the named merge resolves the notes" test "$(git -C "$N2" notes show "$(git -C "$N1" rev-parse HEAD)" 2>/dev/null)" = "note on two"
check "the named merge drops its scratch ref" test -z "$(git -C "$N2" for-each-ref refs/notes/lord-stash)"
git -C "$N2" fetch -q "$NS/.lord-stash/notes.bundle" refs/notes/commits:refs/notes/lord-stash
out=$(cd "$N2" && lord 2>&1)
check "merged notes are saved" test "$out" = "save refs/notes/commits
save refs/notes/extra"
check "the scratch ref stays out of the bundle" sh -c "! git -C '$N2' bundle list-heads '$NS/.lord-stash/notes.bundle' | grep -q lord-stash"
git -C "$N2" update-ref -d refs/notes/lord-stash
out=$(cd "$N1" && lord 2>&1)
check "merged notes load elsewhere" test "$out" = "load refs/notes/commits
load refs/notes/extra"
# a stash in conflict leaves the notes alone
git -C "$N2" notes --ref=extra add -f -m 'extra two' 2>/dev/null
mv "$N2/.git/lord" "$T/stash.aside" && : > "$N2/.git/lord"
out=$(cd "$N2" && lord 2>&1); st=$?
check "stash conflict exits 17" test "$st" = 17
case $out in *"is not a directory"*) ok "stash conflict named";; *) bad "stash conflict: $out";; esac
case $out in *bundle*|*save*) bad "stash conflict touched the notes: $out";; *) ok "stash conflict skips the notes";; esac
rm "$N2/.git/lord" && mv "$T/stash.aside" "$N2/.git/lord"
# a held lock fails the run and leaves the bundle as it is
mkdir "$NS/.lord-stash/notes.lock"
cp "$NS/.lord-stash/notes.bundle" "$T/bundle.before"
out=$(cd "$N2" && lord 2>&1); st=$?
check "held lock exits 16" test "$st" = 16
case $out in *"notes bundle locked by another run"*) ok "held lock named";; *) bad "held lock: $out";; esac
check "held lock leaves the bundle" cmp -s "$NS/.lord-stash/notes.bundle" "$T/bundle.before"
rmdir "$NS/.lord-stash/notes.lock"
out=$(cd "$N2" && lord 2>&1)
check "freed lock lets the save through" test "$out" = "save refs/notes/extra"
check "saves leave no lock or temp file" test -z "$(find "$NS/.lord-stash" -name 'notes.*' ! -name notes.bundle)"
N3="$T/notes3"
git clone -q "$N1" "$N3"
git -C "$N3" remote set-url origin git@github.com:acme/notes.git
git -C "$N3" config core.hooksPath "$T/no-hooks"
out=$(cd "$N3" && lord 2>&1); st=$?
check "unguarded clone exits 0" test "$st" = 0
case $out in *"core.hooksPath is set, so the push guard never runs: notes left alone"*) ok "unguarded clone warns";; *) bad "unguarded: $out";; esac
check "unguarded clone gets no notes" test -z "$(git -C "$N3" for-each-ref refs/notes/)"
cp "$NS/.lord-stash/notes.bundle" "$T/bundle.before"
git -C "$N3" notes --ref=mine add -m 'local only'
(cd "$N3" && lord >/dev/null 2>&1)
check "unguarded clone leaves the bundle alone" cmp -s "$NS/.lord-stash/notes.bundle" "$T/bundle.before"
git -C "$N2" config notes.rewriteRef refs/notes/commits
git -C "$N2" commit -q --amend -m "one, amended" >/dev/null 2>&1
check "post-rewrite saves the moved note" test "$(git -C "$N2" bundle list-heads "$NS/.lord-stash/notes.bundle" | awk '$2 == "refs/notes/commits" { print $1 }')" = "$(git -C "$N2" rev-parse refs/notes/commits)"
check "the note followed the amend" test "$(git -C "$N2" notes show HEAD)" = "other note"
out=$(cd "$N1" && lord eject 2>&1); st=$?
check "eject with a stash exits 0" test "$st" = 0
case $out in "restore .git/lord"*) ok "eject restores the stash first";; *) bad "eject stash: $out";; esac
check "ejected stash is a real dir" test -d "$N1/.git/lord" -a ! -L "$N1/.git/lord"
check "ejected stash keeps the bundle" test -f "$N1/.git/lord/notes.bundle"
check "eject drops the pre-push hook" test ! -e "$N1/.git/hooks/pre-push"
check "eject drops the post-rewrite hook" test ! -e "$N1/.git/hooks/post-rewrite"
printf '\n%d ok, %d failed\n' "$pass" "$failn"
if [ "$failn" = 0 ]; then
rm -rf "$T"
else
printf 'workdir kept: %s\n' "$T"
exit 1
fi