#!/bin/sh # hstow -- POSIX sh minimal reimplementation of GNU Stow for dotfiles # # Copyright (C) 2022 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]*')" if ! command -v readlink >/dev/null; then readlink () { # Safe parse of ls(1) output given its POSIX specification. ls -ld "$1" | tr -s ' ' \ | cut -d' ' -f9- | cut -c$((4 + $(echo "$1" | wc -m)))- } fi read_globs_file () { if [ -e "$DIR/$1" ]; then while read -r line; do printf "|./%s" "$line" done <"$DIR/$1" | cut -c2- fi } dir_contents () { ( cd "$1"; find . ! -name . ! -name "$cchars" 2>/dev/null ) } 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= ignores="$(read_globs_file .hstow-local-ignore)" # 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 || adoptions="$(read_globs_file .hstow-always-adopt)" for file in $(find . ! -name . ! -type d ! -name "$cchars" \ ! -name .gitignore \ ! -name .hstow-local-ignore \ ! -name .hstow-always-adopt \ | grep -v '^\./\.git/'); do file_dir="$(dirname $file)" if [ -n "$ignores" ]; then eval case "'$file'" in "${ignores})" continue ";;" esac eval case "'$file_dir'" in "${ignores})" continue ";;" esac fi rel="$(echo $file|sed -E 's#/dot[-.]([^/]+)#/.\1#g; s#^\./##')" dotdotslashes="$(echo $rel|sed -E 's#[^/]*$##; s#[^/]+#..#g')" target="${dotdotslashes}.STOW/$NAME/$rel" link="$HOME/$rel" link_target= [ -h "$link" ] && link_target="$(readlink $link)" [ "$target" = "$link_target" ] && continue if [ ! -h "$link" -a ! -h "$file" -a -f "$link" ]; then if $always_adopt \ || ( [ -n "$adoptions" ] \ && eval case "'$file'" in \ "${adoptions})" exit 0 ";;" \ "*)" exit 1 ";;" \ esac ); then mv -f "$link" "$file" ln -s "$target" "$link" else conflicts="$conflicts${tab}$file" fi elif [ -h "$link" ]; 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 [ -z "$conflicts" ] && return echo >&2 "hstow: encountered conflicts:" for conflict in $conflicts; do echo >&2 " $conflict"; done exit 127 } unstow () { cd "$HOME" # For speed, skip directories into which we'll never stow anything. ignores="$([ -e "$DIR/.hstow-unstow-ignore" ] || exit 0 while read -r line; do printf "|./%s/" "$line" done <"$DIR/.hstow-unstow-ignore")" dir_pat=".$(echo $DIR | cut -c$(echo $HOME | wc -m | tr -d ' ')-)/" dirs_pat="$(echo "^($dir_pat$ignores)" | sed -e 's#\.#\\.#g')" for file in $(find . -type l ! -name . ! -name "$cchars" 2>/dev/null \ | grep -Ev "$dirs_pat"); do if readlink "$file" | grep -Eq '^(\.\./)*\.STOW/'"$NAME/"; then rm "$file" while true; do file="$(dirname $file)" [ "$file" = . ] && break if [ -z "$(dir_contents $file)" ]; then rmdir "$file" else break fi done fi done [ -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