#!/bin/bash # # Purpose : Install and manage CoreDesktop sibling projects # # A sibling is a git repository cloned into ~/.local/share/siblings/. # 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 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() { 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