From ee7451600b724eb7425b41f5e2e998a6ff3b9313 Mon Sep 17 00:00:00 2001 From: David Young Date: Sun, 8 Nov 2026 10:26:51 -0700 Subject: [PATCH] Show sibling projects' versions, and let version update them A sibling's version is its latest git tag (v1.4.0, or v1.4.0+3 for commits after it), or, without tags, its commit's date and short hash, like CoreDesktop's own. The version shown as available is that of the newest commit on the default branch, which is what update installs. sibling list shows each sibling's version. sibling check fetches every sibling at once and shows which have a newer version, and sibling update [name...] updates the named siblings or all of them. 71-siblings.sh now runs sibling update, so update and version share one way of updating. version lists the installed siblings with any newer version and, when run in a terminal, asks for each one whether to update it now. version --check and the update notification still only look at CoreDesktop. The sibling skill docs describe how to release a version by tagging it. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- sibling | 167 +++++++++++++++++++++++++++--- skills/sibling-project-format.md | 40 ++++++- skills/sibling-project-updates.md | 4 +- tests/sibling.bats | 114 ++++++++++++++++++++ tests/version.bats | 50 ++++++++- updates.d/71-siblings.sh | 17 +-- version | 42 ++++++++ 8 files changed, 401 insertions(+), 37 deletions(-) create mode 100644 tests/sibling.bats diff --git a/README.md b/README.md index ac7aa65..d2d29fa 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,9 @@ sibling It asks for the repository's git URL, clones it into ~/.local/share/siblings/, runs the project's "install.sh" (if it has one), and confirms the install. After that, every "update" pulls the latest version of each sibling and runs its "update.sh" (if it has one). -Use "sibling list" to see what's installed and "sibling remove {name}" to uninstall one. +Use "sibling list" to see what's installed, with each project's version, and "sibling remove {name}" to uninstall one. A project's version is its latest release tag (e.g. v1.4.0), or, if it has none, the date and short hash of its latest commit, like CoreDesktop's own. + +"version" lists your sibling projects too, with any newer version. If one has a newer version, it asks whether to update that project now. You can also run "sibling check" to see what's newer, and "sibling update {name}" to update just that one. To make your own repository work as a sibling project, see [skills/sibling-project-format.md](skills/sibling-project-format.md). diff --git a/sibling b/sibling index cf21917..e185813 100755 --- a/sibling +++ b/sibling @@ -6,6 +6,10 @@ # If it has an install.sh, that runs once when the sibling is added. # Every `update` pulls each sibling and runs its update.sh, if it has one # (see updates.d/71-siblings.sh). +# +# A sibling's version is its latest git tag, e.g. v1.4.0, or v1.4.0+3 for three +# commits after it. A sibling without tags gets CoreDesktop's kind of version: +# the commit's date and short hash, e.g. 2026.11.02 (a1b2c3d). SIBLINGS="$HOME/.local/share/siblings" @@ -13,12 +17,139 @@ usage() { cat << EOF Usage: sibling [command] - add [url] Install a sibling project from a git repository (default) - list Show installed sibling projects - remove Uninstall a sibling project + add [url] Install a sibling project from a git repository (default) + list Show installed sibling projects and their versions + check Show which sibling projects have a newer version + update [name...] Update the named sibling projects, or all of them + remove Uninstall a sibling project EOF } +# Version of the sibling in $1 at commit $2 (HEAD by default) +sibling_version() { + local dir="$1" rev="${2:-HEAD}" tag ahead + if tag="$(git -C "$dir" describe --tags --abbrev=0 "$rev" 2> /dev/null)"; then + ahead="$(git -C "$dir" rev-list --count "$tag..$rev" 2> /dev/null)" + if [[ "$ahead" =~ ^[0-9]+$ ]] && (( ahead > 0 )); then + echo "$tag+$ahead" + else + echo "$tag" + fi + else + git -C "$dir" log -1 --format='%cd (%h)' --date=format:%Y.%m.%d "$rev" + fi +} + +# Installed siblings' directories, one per line, in alphabetical order +sibling_dirs() { + local dir + for dir in "$SIBLINGS"/*/; do + [ -d "$dir/.git" ] && echo "${dir%/}" + done +} + +# Fetch the sibling in $1 and print "nameinstalledavailable", where +# available is the version update would install, empty if it's up to date, or +# ? if the sibling's remote couldn't be checked. +check_one() { + local dir="$1" installed available="" behind + installed="$(sibling_version "$dir")" + if [ -n "$(git -C "$dir" status --porcelain --untracked-files=no 2> /dev/null)" ]; then + installed="$installed, modified" + fi + if ! GIT_TERMINAL_PROMPT=0 timeout 30 git -C "$dir" fetch --quiet &> /dev/null \ + || ! behind="$(git -C "$dir" rev-list --count 'HEAD..@{upstream}' 2> /dev/null)"; then + available="?" + elif (( behind > 0 )); then + available="$(sibling_version "$dir" '@{upstream}')" + fi + printf '%s\t%s\t%s\n' "$(basename "$dir")" "$installed" "$available" +} + +# check_one for every sibling, all at once so a slow remote doesn't hold up the +# rest, printed in alphabetical order. Prints nothing if none are installed. +check_all() { + local dirs dir i=0 tmp + mapfile -t dirs < <(sibling_dirs) + (( ${#dirs[@]} > 0 )) || return 0 + tmp="$(mktemp -d)" + for dir in "${dirs[@]}"; do + check_one "$dir" > "$tmp/$i" & + i=$(( i + 1 )) + done + wait + for (( i = 0; i < ${#dirs[@]}; i++ )); do + cat "$tmp/$i" + done + rm -rf "$tmp" +} + +# Print check_all's lines as a table. Returns 0 if any sibling has a newer +# version, 1 if none does. +show_checked() { + local name installed available newer=1 width=0 vwidth=0 lines=() + mapfile -t lines + for line in "${lines[@]}"; do + IFS=$'\t' read -r name installed _ <<< "$line" + (( ${#name} > width )) && width=${#name} + (( ${#installed} > vwidth )) && vwidth=${#installed} + done + for line in "${lines[@]}"; do + IFS=$'\t' read -r name installed available <<< "$line" + case "$available" in + "") available="up to date" ;; + "?") available="couldn't check" ;; + *) available="$available is available"; newer=0 ;; + esac + printf ' %-*s %-*s %s\n' "$width" "$name" "$vwidth" "$installed" "$available" + done + return $newer +} + +check_siblings() { + if [ "$1" = "--porcelain" ]; then + check_all + return + fi + local checked + checked="$(check_all)" + if [ -z "$checked" ]; then + echo "No sibling projects installed." + return 1 + fi + show_checked <<< "$checked" +} + +# Pull each named sibling (all of them if none are named) and run its update.sh +update_siblings() { + local dirs=() dir name failed=0 + if (( $# == 0 )); then + mapfile -t dirs < <(sibling_dirs) + fi + for name in "$@"; do + if [[ -z "$name" || "$name" == */* || "$name" == .* || ! -d "$SIBLINGS/$name/.git" ]]; then + echo "No sibling named '$name' is installed." + failed=1 + continue + fi + dirs+=("$SIBLINGS/$name") + done + for dir in "${dirs[@]}"; do + name="$(basename "$dir")" + echo "Updating $name..." + if ! git -C "$dir" pull --ff-only --quiet; then + echo " Could not pull $name." + failed=1 + continue + fi + if [ -f "$dir/update.sh" ]; then + (cd "$dir" && bash update.sh) || { echo " $name's update.sh failed."; failed=1; continue; } + fi + echo " $name is at $(sibling_version "$dir")." + done + return $failed +} + # Derive a directory name from a git URL, e.g. # https://host/user/my-project.git -> my-project sibling_name() { @@ -71,7 +202,7 @@ add_sibling() { # Confirm the install if [ -d "$dir/.git" ] && git -C "$dir" rev-parse --verify --quiet HEAD > /dev/null; then - echo "Installed $name at $dir ($(git -C "$dir" log -1 --format='%h %s'))." + echo "Installed $name $(sibling_version "$dir") at $dir." echo "'update' will keep it up to date." else echo "$name does not look installed correctly at $dir." @@ -80,15 +211,25 @@ add_sibling() { } list_siblings() { - local dir found=0 - for dir in "$SIBLINGS"/*/; do - [ -d "$dir/.git" ] || continue - found=1 - printf '%-24s %s (%s)\n' "$(basename "$dir")" \ - "$(git -C "$dir" remote get-url origin 2>/dev/null)" \ - "$(git -C "$dir" log -1 --format='%h, %cr' 2>/dev/null)" + local dirs dir width=0 vwidth=0 versions=() + mapfile -t dirs < <(sibling_dirs) + if (( ${#dirs[@]} == 0 )); then + echo "No sibling projects installed." + return + fi + for dir in "${dirs[@]}"; do + versions+=("$(sibling_version "$dir")") + dir="$(basename "$dir")" + (( ${#dir} > width )) && width=${#dir} + done + for version in "${versions[@]}"; do + (( ${#version} > vwidth )) && vwidth=${#version} + done + for i in "${!dirs[@]}"; do + printf '%-*s %-*s %s +' "$width" "$(basename "${dirs[$i]}")" "$vwidth" "${versions[$i]}" \ + "$(git -C "${dirs[$i]}" remote get-url origin 2> /dev/null)" done - [ $found -eq 1 ] || echo "No sibling projects installed." } remove_sibling() { @@ -116,6 +257,8 @@ remove_sibling() { case "$1" in ""|add) add_sibling "$2" ;; list|ls) list_siblings ;; + check) check_siblings "$2" ;; + update) shift; update_siblings "$@" ;; remove|rm) remove_sibling "$2" ;; -h|--help|help) usage ;; *) usage; exit 1 ;; diff --git a/skills/sibling-project-format.md b/skills/sibling-project-format.md index 83238b1..f754252 100644 --- a/skills/sibling-project-format.md +++ b/skills/sibling-project-format.md @@ -35,7 +35,8 @@ approach or the other, not both, or the project updates twice. ### `update` CoreDesktop runs its own update hooks in order (`10-pacman.sh` … `70-claude.sh`). -Next, `updates.d/71-siblings.sh` takes each sibling in alphabetical order and: +Next, `updates.d/71-siblings.sh` runs `sibling update`, which takes each +sibling in alphabetical order and: 1. Runs `git pull --ff-only --quiet` in its folder. 2. If the pull succeeded and `update.sh` exists, runs `bash update.sh` with the @@ -46,6 +47,12 @@ A failed pull or a non-zero exit from `update.sh` is printed and listed in CoreDesktop's end-of-run hooks (GNOME settings, firmware, cleanup, reboot check) run after all siblings. +### `sibling update [name...]` + +Pulls and runs `update.sh` for the named siblings, or for all of them, exactly +as `update` does, and prints each one's new version. `version` uses it when +the user agrees to update a sibling that has a newer version. + ### `sibling remove ` This only deletes `~/.local/share/siblings/`. CoreDesktop has no uninstall @@ -122,6 +129,28 @@ step for siblings, so anything `install.sh` set up elsewhere stays. caches and per-machine state outside the repo (e.g. `~/.config//`), or into paths listed in `.gitignore`. +### Versions + +CoreDesktop shows a sibling's version in `sibling list`, `sibling check` and +`version`, and works it out from git, so there is no version file to keep: + +- **With tags:** the latest tag reachable from the commit, e.g. `v1.4.0`, or + `v1.4.0+3` three commits after it. To release a version, tag the commit on + the default branch and push the tag: + ```bash + git tag v1.5.0 && git push origin v1.5.0 + ``` +- **Without tags:** the commit's date and short hash, e.g. + `2026.11.02 (a1b2c3d)`, the way CoreDesktop versions itself. + +`update` always installs the newest commit on the default branch, tagged or +not. So the version offered as available is that commit's (`v1.4.0+5` if +nothing since `v1.4.0` has been tagged). Tag a version when it's ready, rather +than holding commits back for a release. + +Annotated and lightweight tags both work, but never move or delete a pushed +tag: the user's clone keeps the old one. + ### Authentication `sibling` clones with plain `git`. A private repo works only if the user can @@ -191,9 +220,10 @@ ln -s /path/to/real/coredesktop "$HOME/.local/share/coredesktop" # Install bash "$HOME/.local/share/coredesktop/sibling" add /path/to/sibling-repo -# Commit a change in /path/to/sibling-repo, then run the update hook the way -# `update` does -( source "$HOME/.local/share/coredesktop/updates.d/71-siblings.sh"; is_installed && run_update ) +# Commit a change in /path/to/sibling-repo, check that it's seen as newer, +# then update the way `update` does +bash "$HOME/.local/share/coredesktop/sibling" check +bash "$HOME/.local/share/coredesktop/sibling" update # Re-run the install to check it is safe to repeat bash "$HOME/.local/share/coredesktop/sibling" add /path/to/sibling-repo @@ -214,6 +244,8 @@ changing. `COREDESKTOP="$HOME/.local/share/coredesktop"` themselves - [ ] Nothing writes to tracked files in the clone, and the default branch is never force-pushed +- [ ] Releases are tagged on the default branch (optional; without tags the + version is the commit's date and hash), and pushed tags are never moved - [ ] The project doesn't also copy a hook into CoreDesktop's `updates.d/` - [ ] The README tells users to install with `sibling add ` diff --git a/skills/sibling-project-updates.md b/skills/sibling-project-updates.md index dbb33f1..1b98ea0 100644 --- a/skills/sibling-project-updates.md +++ b/skills/sibling-project-updates.md @@ -32,7 +32,9 @@ Commands: ```bash sibling # asks for the git URL sibling add # same, without the prompt -sibling list # installed siblings, their URL and current commit +sibling list # installed siblings, their version and URL +sibling check # which siblings have a newer version +sibling update [name] # update the named siblings, or all of them sibling remove # deletes ~/.local/share/siblings/ ``` diff --git a/tests/sibling.bats b/tests/sibling.bats new file mode 100644 index 0000000..cc1497a --- /dev/null +++ b/tests/sibling.bats @@ -0,0 +1,114 @@ +#!/usr/bin/env bats + +load test_helper + +# sibling runs against real git repositories: each sibling is a clone of a +# repo in $ORIGINS, which a test can add commits and tags to. + +setup() { + common_setup + export GIT_AUTHOR_NAME=Test GIT_AUTHOR_EMAIL=test@example.com + export GIT_COMMITTER_NAME=Test GIT_COMMITTER_EMAIL=test@example.com + ORIGINS="$BATS_TEST_TMPDIR/origins" + SIBLINGS="$HOME/.local/share/siblings" + mkdir -p "$ORIGINS" "$SIBLINGS" +} + +# origin NAME: make a repo with one commit, and clone it as a sibling +origin() { + git init -q -b main "$ORIGINS/$1" + commit "$1" first + git clone -q "$ORIGINS/$1" "$SIBLINGS/$1" +} + +# commit NAME MESSAGE: add a commit to NAME's origin +commit() { + echo "$2" >> "$ORIGINS/$1/log" + git -C "$ORIGINS/$1" add -A + git -C "$ORIGINS/$1" commit -qm "$2" +} + +@test "sibling list shows a tagged sibling's tag, and an untagged one's date and hash" { + origin tagged + git -C "$ORIGINS/tagged" tag v1.0.0 + git -C "$SIBLINGS/tagged" pull -q + origin plain + + run bash "$REPO/sibling" list + [ "$status" -eq 0 ] + [[ "$output" == *"tagged v1.0.0 "*"$ORIGINS/tagged"* ]] + [[ "$output" =~ plain\ +[0-9]{4}\.[0-9]{2}\.[0-9]{2}\ \([0-9a-f]+\) ]] +} + +@test "sibling list counts the commits since the latest tag" { + origin tools + git -C "$ORIGINS/tools" tag v1.0.0 + commit tools second + commit tools third + git -C "$SIBLINGS/tools" pull -q + + run bash "$REPO/sibling" list + [[ "$output" == *"tools v1.0.0+2 "* ]] +} + +@test "sibling check shows what's newer, what's up to date, and what it couldn't reach" { + origin newer + git -C "$ORIGINS/newer" tag v1.0.0 + git -C "$SIBLINGS/newer" pull -q + commit newer second + git -C "$ORIGINS/newer" tag v1.1.0 + origin current + origin gone + rm -rf "$ORIGINS/gone" + + run bash "$REPO/sibling" check + [ "$status" -eq 0 ] + [[ "$output" == *"newer v1.0.0"*"v1.1.0 is available"* ]] + [[ "$output" == *"current "*"up to date"* ]] + [[ "$output" == *"gone "*"couldn't check"* ]] + + run bash "$REPO/sibling" check --porcelain + [[ "$output" == *$'newer\tv1.0.0\tv1.1.0'* ]] + [[ "$output" == *$'gone\t'*$'\t?'* ]] +} + +@test "sibling check exits 1 when nothing is newer" { + origin current + run bash "$REPO/sibling" check + [ "$status" -eq 1 ] + [[ "$output" == *"up to date"* ]] +} + +@test "sibling update updates only the siblings named" { + origin one + origin two + echo 'echo ran >> "$HOME/one-updated"' > "$ORIGINS/one/update.sh" + commit one second + git -C "$ORIGINS/one" tag v2.0.0 + commit two second + + run bash "$REPO/sibling" update one + [ "$status" -eq 0 ] + [[ "$output" == *"one is at v2.0.0."* ]] + [ "$(cat "$HOME/one-updated")" = ran ] + [ "$(git -C "$SIBLINGS/two" rev-list --count HEAD)" -eq 1 ] +} + +@test "sibling update with no names updates them all, and reports a failing update.sh" { + origin one + origin two + echo 'exit 1' > "$ORIGINS/one/update.sh" + commit one second + commit two second + + run bash "$REPO/sibling" update + [ "$status" -eq 1 ] + [[ "$output" == *"one's update.sh failed"* ]] + [ "$(git -C "$SIBLINGS/two" rev-list --count HEAD)" -eq 2 ] +} + +@test "sibling update refuses a sibling that isn't installed" { + run bash "$REPO/sibling" update nope + [ "$status" -eq 1 ] + [[ "$output" == *"No sibling named 'nope' is installed."* ]] +} diff --git a/tests/version.bats b/tests/version.bats index 8802dc7..3179e50 100644 --- a/tests/version.bats +++ b/tests/version.bats @@ -4,15 +4,19 @@ load test_helper # version runs from a fake checkout with fake git, uname, pacman and # checkupdates. The running kernel's modules never exist here, so the kernel -# line always also says a newer one is installed. +# line always also says a newer one is installed. Sibling projects are real git +# repositories: only git commands on the fake checkout are faked. setup() { common_setup CD="$HOME/.local/share/coredesktop" mkdir -p "$CD" - cp "$REPO/version" "$REPO/utils.sh" "$CD/" + cp "$REPO/version" "$REPO/utils.sh" "$REPO/sibling" "$CD/" - stub git 'case "$3" in + export CD REAL_GIT + REAL_GIT="$(command -v git)" + stub git '[ "$2" = "$CD" ] || exec "$REAL_GIT" "$@" + case "$3" in rev-parse) exit 0 ;; log) echo "2026.10.31 (6bc4fdc)" ;; fetch) exit 1 ;; @@ -89,3 +93,43 @@ setup() { [ "$status" -eq 0 ] [[ "$output" != *"mirror"* ]] } + +# sibling_repo NAME: a sibling at v1.0.0 whose origin has a newer v1.1.0 +sibling_repo() { + export GIT_AUTHOR_NAME=Test GIT_AUTHOR_EMAIL=test@example.com + export GIT_COMMITTER_NAME=Test GIT_COMMITTER_EMAIL=test@example.com + local origin="$BATS_TEST_TMPDIR/origins/$1" + git init -q -b main "$origin" + echo 'echo "$PWD" >> "$HOME/updated"' > "$origin/update.sh" + git -C "$origin" add -A + git -C "$origin" commit -qm first + git -C "$origin" tag v1.0.0 + git clone -q "$origin" "$HOME/.local/share/siblings/$1" + git -C "$origin" commit -q --allow-empty -m second + git -C "$origin" tag v1.1.0 +} + +@test "version leaves out sibling projects when none are installed" { + run bash "$CD/version" + [ "$status" -eq 0 ] + [[ "$output" != *"Sibling"* ]] +} + +@test "version lists sibling projects, and doesn't ask to update without a terminal" { + sibling_repo tools + run bash "$CD/version" < /dev/null + [ "$status" -eq 0 ] + [[ "$output" == *"Sibling projects"$'\n'" tools v1.0.0, v1.1.0 is available"* ]] + [[ "$output" != *"Update tools"* ]] + [ ! -f "$HOME/updated" ] +} + +@test "version updates the sibling projects the user says yes to" { + sibling_repo keep + sibling_repo tools + run script -qec "bash '$CD/version'" /dev/null <<< $'n\ny' + [ "$status" -eq 0 ] + [[ "$output" == *"Update keep to v1.1.0? [y/N]"* ]] + [[ "$output" == *"tools is at v1.1.0."* ]] + [ "$(cat "$HOME/updated")" = "$HOME/.local/share/siblings/tools" ] +} diff --git a/updates.d/71-siblings.sh b/updates.d/71-siblings.sh index 4e02bd9..8498d1d 100755 --- a/updates.d/71-siblings.sh +++ b/updates.d/71-siblings.sh @@ -8,20 +8,5 @@ is_installed() { } run_update() { - echo "Updating sibling projects..." - local dir name failed=0 - for dir in "$SIBLINGS"/*/; do - [ -d "$dir/.git" ] || continue - name="$(basename "$dir")" - echo " $name" - if ! git -C "$dir" pull --ff-only --quiet; then - echo " Could not pull $name." - failed=1 - continue - fi - if [ -f "$dir/update.sh" ]; then - (cd "$dir" && bash update.sh) || { echo " $name's update.sh failed."; failed=1; } - fi - done - return $failed + bash "$COREDESKTOP/sibling" update } diff --git a/version b/version index 4391d63..744dd26 100755 --- a/version +++ b/version @@ -8,6 +8,9 @@ # because they come from Arch, not CoreDesktop, along with any newer release of # either that update would install. # +# Sibling projects (see sibling) are listed with their versions too. If any has +# a newer version, version asks, for each one, whether to update it now. +# # version --check prints the newer version and exits 0 if one is available, # exits 1 if this machine is up to date, or 2 if it couldn't check. @@ -125,6 +128,29 @@ echo "Kernel $kernel" [ -n "$driver" ] && echo "Video driver $driver" echo "Last update $last_update" +# Sibling projects, as "nameinstalledavailable" lines (see sibling) +siblings="$(bash "$COREDESKTOP/sibling" check --porcelain 2> /dev/null)" +newer_siblings=() +if [ -n "$siblings" ]; then + echo + echo "Sibling projects" + width=0 + while IFS=$'\t' read -r name _; do + (( ${#name} > width )) && width=${#name} + done <<< "$siblings" + while IFS=$'\t' read -r name installed available; do + case "$available" in + "") ;; + "?") installed="$installed, couldn't check for a newer version" ;; + *) + installed="$installed, $available is available" + newer_siblings+=("$name"$'\t'"$available") + ;; + esac + printf ' %-*s %s\n' "$width" "$name" "$installed" + done <<< "$siblings" +fi + # Packages newer than the mirror's last sync aren't shown above, or installed if age="$(mirror_age_hours)" && (( age >= MIRROR_STALE_HOURS )); then if (( age >= 48 )); then ago="$(( age / 24 )) days"; else ago="$age hours"; fi @@ -132,3 +158,19 @@ if age="$(mirror_age_hours)" && (( age >= MIRROR_STALE_HOURS )); then echo -e "\e[33mYour package mirror ($(first_mirror)) last synced with Arch $ago ago, so" echo -e "newer packages may be missing. Run update to switch to up-to-date mirrors.\e[0m" fi + +# Offer each newer sibling, but only to someone at the terminal to answer +if (( ${#newer_siblings[@]} > 0 )) && [ -t 0 ] && [ -t 1 ]; then + echo + chosen=() + for sibling in "${newer_siblings[@]}"; do + IFS=$'\t' read -r name available <<< "$sibling" + read -rp "Update $name to $available? [y/N] " response + [[ "$response" =~ ^[yY]([eE][sS])?$ ]] && chosen+=("$name") + done + if (( ${#chosen[@]} > 0 )); then + echo + bash "$COREDESKTOP/sibling" update "${chosen[@]}" + exit + fi +fi