Symbolic links and gitignore

I've often seen developers, myself included, use .gitignore patterns[1] that do not work when used with symbolic links (symlinks)[2]. While not universal, it's common to leave a trailing / on the end of a directory name to indicate it is a directory.

However, if you use a trailing / in a gitignore pattern you will have issues with symbolic links. My gut feeling was that Git would treat a symbolic link like what it's linking to. But since symbolic links are a 'special kind of file that points to another file'[2], Git seems to treat them as files. As this has tripped me up a couple of times I want to confirm this and come up with some best practices for the future.

Symbolic links

So from the results below we can confirm that Git treats symbolic links as files, regardless of what they are linking to. However, another reason for using a trailing / is to notate the end of the pattern. So we should confirm that omitting it doesn't cause unintended files or directories to be ignored, before recommending to not include trailing /'s.


Type target target/
File 🚫 📦
Symbolic Link → File 🚫 📦
Directory 🚫 🚫
Symbolic Link → Directory 🚫 📦

gitignore-symlink-test.sh
#!/usr/bin/env sh

set -o errexit
set -o nounset

# Emoji output: 🚫 = ignored, 📦 = tracked
check_ignored() {
	git ls-files . --exclude-standard --others | grep -q -E "$1" && echo "📦" || echo "🚫"
}

run_test() {
	test_dir=$(mktemp -d)
	cd "${test_dir}"
	git init >/dev/null 2>&1

	case "$1" in
		real_file)
			touch "target"
			;;
		symlink_file)
			touch "source_file"
			ln -s "source_file" "target"
			;;
		real_dir)
			mkdir "target"
			touch "target/inner.txt"
			;;
		symlink_dir)
			mkdir "source_dir"
			touch "source_dir/inner.txt"
			ln -s "source_dir" "target"
			;;
	esac

	echo "target" > .gitignore
	result_no_slash=$(check_ignored "^target($|/)")

	echo "target/" > .gitignore
	result_with_slash=$(check_ignored "^target($|/)")

	echo "| $2 | ${result_no_slash} | ${result_with_slash} |"
}

echo "| Type | \`target\` | \`target/\` |"
echo "|---|---|---|"
run_test "real_file" "File"
run_test "symlink_file" "Symbolic Link → File"
run_test "real_dir" "Directory"
run_test "symlink_dir" "Symbolic Link → Directory"

Prefix

From the results we can confirm that Git does not prefix match. A pattern of target will not match target-extra, so omitting the trailing / is safe.

Type target target/
File 📦 📦
Symbolic Link → File 📦 📦
Directory 📦 📦
Symbolic Link → Directory 📦 📦

gitignore-prefix-test.sh
#!/usr/bin/env sh

set -o errexit
set -o nounset

# Emoji output: 🚫 = ignored, 📦 = tracked
check_ignored() {
	git ls-files . --exclude-standard --others | grep -q -E "$1" && echo "📦" || echo "🚫"
}

run_test() {
	test_dir=$(mktemp -d)
	cd "${test_dir}"
	git init >/dev/null 2>&1

	case "$1" in
		file)
			touch "target-extra"
			;;
		symlink_file)
			touch "source_file"
			ln -s "source_file" "target-extra"
			;;
		dir)
			mkdir "target-extra"
			touch "target-extra/inner.txt"
			;;
		symlink_dir)
			mkdir "source_dir"
			touch "source_dir/inner.txt"
			ln -s "source_dir" "target-extra"
			;;
	esac

	echo "target" > .gitignore
	result_no_slash=$(check_ignored "^target-extra($|/)")

	echo "target/" > .gitignore
	result_with_slash=$(check_ignored "^target-extra($|/)")

	echo "| $2 | ${result_no_slash} | ${result_with_slash} |"
}

echo "| Type | \`target\` | \`target/\` |"
echo "|---|---|---|"
run_test "file" "File"
run_test "symlink_file" "Symbolic Link → File"
run_test "dir" "Directory"
run_test "symlink_dir" "Symbolic Link → Directory"