From f223f38fcab3c94402603d1fadb2d6fa0ac3d05a Mon Sep 17 00:00:00 2001 From: Sean Whitton Date: Fri, 11 Nov 2022 23:32:12 -0700 Subject: GNU Stow -> hstow, and follow-up tidying & simplifications --- bin/hstow | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100755 bin/hstow (limited to 'bin/hstow') 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 +# +# 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 -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 -- cgit v1.2.3