#!/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 . # # USAGE # # hstow stow|unstow|restow|adopt DIRECTORY # # For example, if ~/dotfiles/ is a VCS repository with master copies of # your .profile, .inputrc, .config/git/config, etc., you can use # # % hstow stow ~/dotfiles # # to create links ~/.profile -> ~/dotfiles/.profile, # ~/.inputrc -> ~/dotfiles/.inputrc, # ~/.config/git/config -> ~/dotfiles/.config/git/config # etc.. # # ADOPTION # # Some programs will replace a symlink to a stowed file with a regular # copy of the file, and a subset of these will do this even if they # haven't edited the file. This will cause 'hstow stow' to fail. # # To deal with this, use the 'adopt' subcommand. This will move the # modified file into your repository, and restore the symlink. # Then you can use VCS tools ('git diff', 'hg diff', etc..) to decide # whether to keep any changes. # # INSTALLATION # # This script is POSIX sh-compatible: it should run almost anywhere. # # Copy hstow, posix-defuns.sh and globs2ere.awk from my dotfiles.git # into your own, and update the paths to the latter files embedded in # the first. You might then incorporate restowing into any bootstrap # scripts you have. I use joeyh's myrepos, and cf. my 'bstraph' script. # # CONFIGURATION # # There are three optional configuration files in the root of DIRECTORY. # Each is a list of globs, one per line, with blank lines ignored. # There is no comment syntax. These globs are for matching, not # expansion: '/' and '.' are not treated specially. # # .hstow-local-ignore # # Files under DIRECTORY which should not be symlinked. # E.g. 'archive/*'. # # .hstow-unstow-ignore # # Paths under HOME which should not be scanned for symlinks to # remove. By default, 'hstow unstow' looks at every symlink under # HOME to see whether it's one we created: this has the advantage # that if a 'git pull' or 'hg up' removes a file, it's still # possible to remove the dangling symlink. But this can be slow. # # If you have directories under HOME into which you'll never stow # anything but which contain a lot of symlinks and/or subdirectory # structure, list globs matching those here. E.g. 'annex/*'. # # .hstow-always-adopt # # Paths under HOME which 'hstow stow' should treat in the manner # that 'hstow adopt' does. E.g. '.config/mimeapps.list'. # List globs matching files for which you don't ever want to edit # the copy under DIRECTORY directly, but only via the link/copy # under HOME. # # TIPS # # hstow works well with multiple repositories, perhaps dotfiles/ and # work-dotfiles/. In secondary repositories you can create and commit # relative symlinks to the primary repository's .hstow-unstow-ignore. # # COMMENTARY # # The point of this script is to obtain 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 # # GNU Stow, a Perl program. # set -efu IFS="$(printf '\n\t')" export LC_ALL=C . ~/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" } globs_to_find_args () { local file="$DIR/$1"; shift printf '%s\n' "$@" | cat - $([ -e "$file" ] && echo "$file") \ | awk -F'\n' -vOFS='\t' \ '/\/\*$/ { sub(/..$/, ""); prune[++c] = "./" $0; next } $0 { notpath[++d] = "./" $0 } # We want to prevent find(1) recursing into directories # of these names, but not prevent the remainder of the # find(1) expression from matching the names themselves. # This is what is correct for globs of the form "dir/*". # While it is true that neither of the find(1) commands # which use globs_to_find_args() match directories, such # that it would not make sense to add a line to one of # .hstow-*-ignore with the aim of excluding the contents # of a directory but not the directory itself, it could # be that the name is currently a symlink that we should # unstow. (Also note that we do not pass -L to find(1), # so we will not recurse into symlinks to dirs anyway.) END { if (c) { print "(", "(", "-path", prune[1] for (i = 2; i <= c; i++) print "-o", "-path", prune[i] print ")", "-prune", "-o", "-name", "*", ")" } for (j in notpath) print "!", "-path", notpath[j] }' } 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 () { if $always_adopt; then adoptp=1 elif ! [ -e .hstow-always-adopt ]; then adoptp=0 else adoptp="$(printf \ 'rel ~ /%s/' \ "$(awk -f ~/src/dotfiles/lib-src/globs2ere.awk \ .hstow-always-adopt)")" fi # 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. # See . find . $(globs_to_find_args .hstow-local-ignore ".git/*") \ ! -name . ! -type d ! -name "$cchars" ! -name .gitignore \ ! -name .hstow-local-ignore ! -name .hstow-always-adopt \ ! -name .hstow-unstow-ignore -print \ | awk -F'\n' -vOFS='\t' ' { 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 [ -n "$link_target" ]; then # implies $link is a symlink # 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" elif $adopt && [ ! -h "$file" -a -f "$link" ]; then mv -f "$link" "$file" ln -s "$target" "$link" elif [ -d "$link" ] && rmdir "$link" 2>/dev/null; then ln -s "$target" "$link" elif [ -e "$link" ]; then echo "$file" # into $conflicts else mkdir -p "$HOME/$file_dir" ln -s "$target" "$link" fi done } unstow () { cd "$HOME" # 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 \ $(globs_to_find_args \ .hstow-unstow-ignore ".STOW/*" \ "$(echo "$DIR" \ | cut -c$((1 + $(echo "$HOME" | wc -m)))-)/*") \ -type l ! -name . ! -name "$cchars" -print \ | 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