Compare commits

...

2 Commits

Author SHA1 Message Date
7f83427749 hooks:pre-commit: Broken link detection on delete
Until now the hook only checked newly added symlinks. This patch is a
first draft of also checking the worktree and index for any dangling
symlinks after staging a deletion.

The whole thing probably breaks when file-names contain newlines and
maybe also a mix of quotes. I plan on making this more robust in the
future but see no urgency for it since this repository has pretty simple
filenames.
2025-09-15 17:40:35 +02:00
8a4029121e hooks:pre-commit: Explicitly propagate pipe error
The `die` in the pipe does not exit the whole script but only the pipe.
This currently works because the pipe is the last thing being executed.
If something would come after the pipe, the hook would happily continue
executing. Properly propagate the exit to prevent this in the future.
2025-09-15 17:34:01 +02:00

View File

@@ -62,4 +62,119 @@ git diff --staged --name-only --diff-filter=AT $against \
fi
done
[ "$abort" -eq 0 ] || die
}
} || exit
# Make sure that a deletion does not break any symlinks (including renaming a
# file)
# TODO: switch all these to null-terminated lines
deleted_files="$(git diff-index --cached --name-only --diff-filter=D $against)"
if [ -n "$deleted_files" ]; then
# First, check for broken symlinks in the tree
all_broken_links="$(find . -xtype l -exec stat -c '%N' '{}' '+')"
# NOTE: The cat could be replaced by instead adding the heredoc to the
# `done` of the loop, but would make the code much less readable
cat <<EOF \
| while read -r deletion
$deleted_files
EOF
do
# As a first heuristic, check if there is a broken symlink with
# a target with the same basename as the deleted file
#
# TODO: stat escapes quotes sometimes. Does everything work
# then?
possible_links="$(
grep "[\"'/]$(
basename "$deletion" \
| sed 's/[.[^$*\\]/\\&/g'
)[\"']\$" <<EOF
$all_broken_links
EOF
)"
[ -n "$possible_links" ] || continue
cat << EOF \
| while read -r link
$possible_links
EOF
do
# TODO: this is probably quite brittle, depending on how
# `stat` quotes source and target
target="${link##* -> [\"\']}"
target="${target%[\"\']}"
source="${link%%[\"\'] -> *}"
source="${source#[\"\']}"
if [ -z "${target##/*}" ]; then
# absolute link
if [ "$target" = "$PWD/$deletion" ]; then
die "You broke the symlink $link"
fi
else
# relative link
target="$(realpath -m "$source/../$target")"
if [ "$target" = "$PWD/$deletion" ]; then
die "You broke the symlink $link"
fi
fi
done || exit
done || exit
# Second, check all symlinks in the index if they still point to the
# deleted file
all_links_in_index="$(
git ls-files --format="%(objectmode) %(objectname) %(path)" \
| grep '^120000'
)"
cat <<EOF \
| while read -r deletion
$deleted_files
EOF
do
# As a first heuristic, get all links in the tree with a target
# with the same basename as the deleted file
possible_links="$(
cut -d' ' -f2 <<EOF \
| git cat-file --batch \
| grep -B1 "\(^\|/\)$(
basename "$deletion" \
| sed 's/[.[^$*\\]/\\&/g'
)\$" \
| paste - -
$all_links_in_index
EOF
)"
[ -n "$possible_links" ] || continue
cat << EOF \
| while read -r link
$possible_links
EOF
do
target="${link#* }"
source="$(
grep -F "${link%% *}" <<EOF \
| cut -d' ' -f3-
$all_links_in_index
EOF
)"
if [ -z "${target##/*}" ]; then
# absolute link
if [ "$target" = "$PWD/$deletion" ]; then
die "You broke the symlink \"$source\" -> \"$target\""
fi
else
# relative link
target="$(realpath -m "$source/../$target")"
if [ "$target" = "$PWD/$deletion" ]; then
die "You broke the symlink \"$source\" -> \"$target\""
fi
fi
done || exit
done || exit
# TODO: also check potential symlinks pointing to now empty directories
fi