summaryrefslogtreecommitdiff
path: root/bin/hstow
diff options
context:
space:
mode:
authorSean Whitton <spwhitton@spwhitton.name>2022-11-11 23:32:12 -0700
committerSean Whitton <spwhitton@spwhitton.name>2022-11-12 11:23:53 -0700
commitf223f38fcab3c94402603d1fadb2d6fa0ac3d05a (patch)
tree8036d67f8e96d335368d8b1ff19d3c98770fd754 /bin/hstow
parent74585ec4711667c76ecdad7eb53590cb912501ba (diff)
downloaddotfiles-f223f38fcab3c94402603d1fadb2d6fa0ac3d05a.tar.gz
GNU Stow -> hstow, and follow-up tidying & simplifications
Diffstat (limited to 'bin/hstow')
-rwxr-xr-xbin/hstow172
1 files changed, 172 insertions, 0 deletions
diff --git a/bin/hstow b/bin/hstow
new file mode 100755
index 00000000..d151504b
--- /dev/null
+++ b/bin/hstow
@@ -0,0 +1,172 @@
+#!/bin/sh
+
+# hstow -- POSIX sh minimal reimplementation of GNU Stow for dotfiles
+#
+# Copyright (C) 2022 Sean Whitton <spwhitton@spwhitton.name>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+# 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 <https://dwheeler.com/essays/filenames-in-shell.html>.
+
+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 -c)))-
+ }
+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" )
+}
+
+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 just 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"
+ dir_pat="^.$(echo $DIR | cut -c$(echo $HOME | wc -c)-)/"
+ for file in $(find . -type l ! -name . ! -name "$cchars" \
+ | grep -v "$dir_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 -c) - 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