Show sibling projects' versions, and let version update them
shellcheck / shellcheck (push) Failing after 19s

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 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 10:26:51 -07:00
co-authored by Claude Opus 5.5
parent 318e858021
commit ee7451600b
8 changed files with 401 additions and 37 deletions
+3 -1
View File
@@ -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).
+155 -12
View File
@@ -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 <name> 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 <name> 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 "name<TAB>installed<TAB>available", 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 ;;
+36 -4
View File
@@ -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 <name>`
This only deletes `~/.local/share/siblings/<name>`. 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/<name>/`), 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 <url>`
+3 -1
View File
@@ -32,7 +32,9 @@ Commands:
```bash
sibling # asks for the git URL
sibling add <url> # 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 <name> # deletes ~/.local/share/siblings/<name>
```
+114
View File
@@ -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."* ]]
}
+47 -3
View File
@@ -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" ]
}
+1 -16
View File
@@ -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
}
+42
View File
@@ -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 "name<TAB>installed<TAB>available" 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