#!/bin/sh # hstow -- POSIX sh minimal reimplementation of GNU Stow for dotfiles # # Copyright (C) 2022-2023 Sean Whitton # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or (at # your option) any later version. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # The point of this script is to obtain minimally functional dotfiles # handling in one's home directory on even machines that lack a Perl # interpreter. As such, many of GNU Stow's more advanced features are # not reimplemented. Git depends on Perl, for now, but my two methods # for deploying my dotfiles to remote machines -- my INSINUATE-DOTFILES # Consfigurator property and 'insinuate-dotfiles' shell script -- do not # depend on having Git on the remote side. See also 'bstraph' script. # # We completely skip filenames containing control characters, including # newline and tab, as POSIX find(1) lacks -print0, and it's unlikely # you'd need to stow any such files. # Technique from . set -efu IFS="$(printf '\n\t')" export LC_ALL=C tab="$(printf '\t')" cchars="$(printf '*[\001-\037\177]*')" . ~/src/dotfiles/lib-src/posix-defuns.sh # readlink(1) is not POSIX, so we require an alternative. # For simplicity, always use this even if readlink(1) is installed. readlinks () { local restc=$1 assume="${2:-false}" first= rest= local fields_temp="$(mkstemp)" targets_temp="$(mkstemp)" # This first pipeline involves a safe parse of ls(1) output given # its POSIX specification. # We want the ninth field onwards in the output of ls(1) in awk's # sense -- i.e. collapsing runs of multiple blanks between the first # nine fields -- but we mustn't collapse blanks from there onwards. exec 3>&1 while read -r first rest; do [ -n "$rest" ] && rest="$tab$rest" if $assume || [ -h "$first" ]; then echo "$first" echo "$first$rest" >>"$fields_temp" else echo "$first$rest" >&3 fi done \ | awk '{ gsub(/"/, "\"'"'"'\"'"'"'\"") printf "\"%s\"\n", $0 }' | xargs -E '' -- ls -ld \ | sed -E 's/^([^[:blank:]]+[[:blank:]]+){8}//; /^\.$/d' \ | sort -o "$targets_temp" # undo how ls(1) sorts non-dirs first sort -t"$tab" -k1 -o "$fields_temp" "$fields_temp" if [ "$(wc -l <"$fields_temp")" \ -ne "$(wc -l <"$targets_temp")" ]; then rm "$fields_temp" "$targets_temp" fail "line counts unequal; are there newlines in link targets?" fi # Only BREs have backreferences in POSIX. paste "$fields_temp" "$targets_temp" \ | sed 's/^\([^\t]\{1,\}\)\t\('\ "$(printf "%${restc}s" | sed 's/ /[^\\t]\\{1,\\}\\t/g')"\ '\)\1 -> /\1\t\2/' \ | awk -F'\t' 'NF == '$((2+$restc))' && $NF !~ /[\001-\037\177]/' rm "$fields_temp" "$targets_temp" } disjoin_file () { if [ -e "$DIR/$1" ]; then while read -r line; do [ -n "$line" ] && printf "|$2" "$line" done <"$DIR/$1" | sed 's#^.##; s#/#\\/#g' fi } dir_contents () { ( cd "$1"; find . ! -name . ! -name "$cchars" -print -prune ) } fail () { echo >&2 "hstow: $*" exit 127 } usage () { fail "usage: hstow stow|unstow|restow|adopt DIRECTORY" } stow () { cd "$DIR" [ -d "$HOME/.STOW" ] || mkdir "$HOME/.STOW" [ -f "$HOME/.STOW/.stow" ] || touch "$HOME/.STOW/.stow" [ -h "$HOME/.STOW/$NAME" ] \ || ( cd "$HOME/.STOW"; ln -s "$DIR" "$NAME" ) conflicts="$(stow1)" [ -z "$conflicts" ] && return echo >&2 "hstow: encountered conflicts:" for conflict in $conflicts; do echo >&2 " $conflict"; done exit 127 } stow1 () { ignores="$(disjoin_file .hstow-local-ignore "./%s")" # Files that (i) always/often have their symlinks replaced with # regular files when applications access them; and (ii) we don't # ever want to edit the copy under $DIR directly, but only via the # link/copy under $HOME. $always_adopt \ && adoptp=1 \ || adoptp="rel ~ /^($(disjoin_file .hstow-always-adopt "%s"))/" find . ! -name . ! -type d ! -name "$cchars" \ ! -name .gitignore \ ! -name .hstow-local-ignore \ ! -name .hstow-always-adopt \ ! -name .hstow-unstow-ignore \ | awk -F'\n' -vOFS='\t' '! /^(\.\/\.git\/|'"$ignores"')/ \ { rel = $1; gsub(/\/dot[-.]/, "/.", rel); gsub(/^\.\//, "", rel) dotdotslashes = rel sub(/[^\/]*$/, "", dotdotslashes) gsub(/[^\/]+/, "..", dotdotslashes) $2 = $1 $3 = $1; sub(/\/[^/]+$/, "", $3) $1 = ENVIRON["HOME"] "/" rel $4 = '"$adoptp"' ? "true" : "false" $5 = dotdotslashes ".STOW/'"$NAME"'/" rel print }' | readlinks 4 \ | while read -r link file file_dir adopt target link_target; do if [ "$target" = "$link_target" ]; then continue elif [ -z "$link_target" -a ! -h "$file" -a -f "$link" ]; then if $adopt; then mv -f "$link" "$file" ln -s "$target" "$link" else echo "$file" # into $conflicts fi elif [ -n "$link_target" ]; then # With at least GNU ln(1), passing -f, but not also -T, does # not replace an existing link in some cases. # -T is not POSIX, so we remove any existing link first. rm "$link" ln -s "$target" "$link" else mkdir -p "$HOME/$file_dir" ln -s "$target" "$link" fi done } unstow () { cd "$HOME" # For speed, skip directories into which we'll never stow anything. ignores="$(disjoin_file .hstow-unstow-ignore "./%s")" dir_pat=".$(echo $DIR | cut -c$(echo $HOME | wc -m | tr -d ' ')-)/" dirs_pat="$(echo "^($dir_pat|$ignores)" | sed -e 's#\.#\\.#g')" # awk's close() calls pclose(3), completing all the link deletions. # POSIX.1 "Utility Description Defaults", "Consequences of Errors" # implies that should rmdir(1) encounter a non-empty directory, it # should proceed to attempt to remove the next operand. # Thus, here, -p means that we do not need to sort the operands. # We don't know the code with which rmdir(1) will exit, and if it is # 255 then xargs will give up. So we wrap in a call to sh -c. find . ! \( -user "$(id -un)" -o -group "$(id -gn)" \) \ -prune -o -type l ! -name . ! -name "$cchars" -print \ | grep -Ev "$dirs_pat" | readlinks 0 true \ | awk -F'\t' -vOFS='\t' '$2 ~ /^(\.\.\/)*\.STOW\/'"$NAME"'\// \ { gsub(/"/, "\"'"'"'\"'"'"'\"", $1) printf "\"%s\"\n", $1 | "xargs -E '' -- rm -f" sub(/^.\//, "", $1) sub(/\/?[^/]+$/, "", $1) if ($1) dirs[++i] = $1 } END { close("xargs -E '' -- rm -f") for (d in dirs) printf "\"%s\"\n", dirs[d] }' | xargs -E '' -- sh -c 'rmdir -p -- "$@" 2>/dev/null ||:' -- [ -e "$HOME/.STOW/$NAME" ] && rm "$HOME/.STOW/$NAME" if [ -d "$HOME/.STOW" ] \ && [ "$(dir_contents $HOME/.STOW)" = "./.stow" ]; then rm "$HOME/.STOW/.stow" rmdir "$HOME/.STOW" fi } [ $# = 2 ] || usage [ -d "$2" ] || fail "$2 is not an existing directory" DIR="$(cd $2; pwd)" [ "$(echo $DIR | cut -c-$(($(echo $HOME | wc -m) - 1)))" = "$HOME" ] \ || fail "$DIR is not below $HOME" NAME="$(echo $DIR | tr / _)" always_adopt=false case "$1" in 'stow') stow ;; 'unstow') unstow ;; 'restow') unstow; stow ;; 'adopt') always_adopt=true; stow ;; *) usage ;; esac