Files
coredesktop/sibling
T
davidandClaude Opus 5.5 ee7451600b
shellcheck / shellcheck (push) Failing after 19s
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 <noreply@anthropic.com>
2026-11-08 10:26:51 -07:00

266 lines
7.7 KiB
Bash
Executable File

#!/bin/bash
#
# Purpose : Install and manage CoreDesktop sibling projects
#
# A sibling is a git repository cloned into ~/.local/share/siblings/<name>.
# 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"
usage() {
cat << EOF
Usage: sibling [command]
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() {
local name="${1%/}"
name="${name##*/}"
name="${name##*:}"
echo "${name%.git}"
}
add_sibling() {
local url="$1" name dir
if [ -z "$url" ]; then
read -rp "Git repository URL for the sibling project: " url
fi
if [ -z "$url" ]; then
echo "No URL given."
return 1
fi
name="$(sibling_name "$url")"
if [[ -z "$name" || "$name" == .* ]]; then
echo "Could not work out a project name from: $url"
return 1
fi
dir="$SIBLINGS/$name"
mkdir -p "$SIBLINGS"
if [ -d "$dir/.git" ]; then
local existing
existing="$(git -C "$dir" remote get-url origin 2>/dev/null)"
if [ "$existing" != "$url" ]; then
echo "A different sibling named '$name' is already installed from: $existing"
return 1
fi
echo "$name is already installed; pulling the latest version..."
git -C "$dir" pull --ff-only || return 1
else
echo "Cloning $name..."
git clone "$url" "$dir" || return 1
fi
if [ -f "$dir/install.sh" ]; then
echo "Running $name's install.sh..."
if ! (cd "$dir" && bash install.sh); then
echo "$name's install.sh failed. Fix the problem and run 'sibling add $url' again."
return 1
fi
fi
# Confirm the install
if [ -d "$dir/.git" ] && git -C "$dir" rev-parse --verify --quiet HEAD > /dev/null; then
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."
return 1
fi
}
list_siblings() {
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
}
remove_sibling() {
local name="$1"
if [[ -z "$name" || "$name" == */* || "$name" == .* ]]; then
usage
return 1
fi
if [ ! -d "$SIBLINGS/$name" ]; then
echo "No sibling named '$name' is installed."
return 1
fi
read -rp "Remove $SIBLINGS/$name? [y/N] " response
case "$response" in
[yY][eE][sS]|[yY])
rm -rf "${SIBLINGS:?}/$name"
echo "Removed $name. Anything its install.sh set up outside that folder is still there."
;;
*)
echo "Nothing removed."
;;
esac
}
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 ;;
esac