Make git follow symbolic links — opt-in, with a clear warning before the first
real action — without forking git, without patching git, and without breaking
across git releases.
┌─────────────────────────────────────────────────────────────────────┐
│ WARNING: this repo is about to follow the following symbolic links: │
│ │
│ hooks → /home/trent/Desktop/claude/hooks (62 files, 1.2 MB) │
│ art → /shared/ArcoMage/forum/ (11 files, 8.4 MB) │
│ │
│ All files under those targets will be included in commits made │
│ from this repo. Proceed? │
│ │
│ [1] add the link path to .gitignore │
│ [2] continue once │
│ [3] continue always (don't ask again on this repo) │
└─────────────────────────────────────────────────────────────────────┘
Git intentionally does not follow symbolic links. The maintainers have rejected
this feature on the mailing list dozens of times across 20 years. Their
philosophical position is that git is a content-addressable storage system and
filesystem semantics belong to the shell. The technical concern is real but
addressable: a malicious commit shipping a link to /etc/passwd or
~/.ssh/id_rsa is a security problem if the tool follows links blindly.
git-symlinks follows symbolic links opt-in, with the warning shown above
before any data is committed or checked out. The user explicitly decides per
repo whether to allow it.
git-symlinks extends git through four documented, frozen APIs:
| Surface | What we do |
|---|---|
.gitattributes |
Mark paths as filter=symlinks so git pipes their content through our filter |
| Clean filter | On git add, dereference the symlink, store target's content + metadata header |
| Smudge filter | On git checkout, emit the stored content as a marker file |
post-checkout hook |
Scan working tree for markers; recreate symlinks if target exists, else leave content in place |
None of those surfaces have changed in over a decade. git-lfs and git-annex
have ridden the same APIs across hundreds of git releases without an emergency
port. git-symlinks inherits that durability for free.
─── git add path/to/link ───────────────────────────────────────────────
git reads .gitattributes → "path filter=symlinks"
git pipes the file (raw symlink bytes) through `git-symlinks clean`
clean filter dereferences the link, reads target content, emits:
__GIT_SYMLINKS__\n
target=../../shared/hooks\n
sha256=abc123...\n
size=12345\n
\n
<raw target content bytes>
git hashes that blob and stores it. Done.
─── git checkout HEAD path/to/link ─────────────────────────────────────
git reads the blob, pipes it through `git-symlinks smudge`.
smudge passes the blob through unchanged → marker file at path/to/link.
Then git fires the `post-checkout` hook.
`git-symlinks post-checkout` walks the working tree:
- finds files starting with __GIT_SYMLINKS__
- for each:
if the target path resolves on this machine → rm marker, ln -s target
else → strip header, leave content
Result: on the machine that has the target, you get a real symlink. On a machine that doesn't (CI, another developer), you get the content materialized at the link's location — repo still works.
pip install git-symlinks # not yet — placeholder
# or build from source:
git clone https://github.com/tibberous/git-symlinks
cd git-symlinks
pip install -e .
After install, in any repo:
git symlinks init # one-time per repo; installs filter + hook config
git symlinks add path/to/link # equivalent to `git add` but warns + dereferences
git commit -m "track shared/hooks via deref"
Other developers cloning the repo will see the warning UX on first checkout.
| Command | What it does |
|---|---|
git symlinks init |
Install clean/smudge filter config + post-checkout hook in the current repo. Idempotent. |
git symlinks add <path> |
Wraps git add. Warns about each symlink-followed path before staging. |
git symlinks status |
List currently-deref'd paths in this repo + their resolution state. |
git symlinks clean |
Internal — called by git's clean filter. Not for direct invocation. |
git symlinks smudge |
Internal — called by git's smudge filter. |
git symlinks post-checkout |
Internal — called by git's post-checkout hook. |
git symlinks uninstall |
Removes filter + hook from the current repo. Content stays in place. |
git symlinks --usage |
Worked examples. |
git symlinks --help |
Argparse help. |
| Threat | Mitigation |
|---|---|
Malicious symlink to /etc/passwd |
Warning UX shown before first checkout / first commit ever resolves a link. Default is refuse. User must explicitly pick "continue" or "always continue." |
Symlink to outside the repo (e.g. ~/.ssh/id_rsa) |
Same warning + the target's path is shown in full so the user can see exactly what's about to be committed. |
| Symlink cycles | Cycle detection — depth limit + visited-set during walk. |
| Stale target on another machine | Falls back to materialized content (no error, no exfiltration). |
| User says "always continue" once then a malicious commit adds a new link | A new symlink added to the repo re-triggers the warning even after "always continue" — only the previously approved links are silent. |
| Platform | Status |
|---|---|
| Linux | first-class |
| macOS | first-class |
| Windows (real symlinks, dev mode / admin) | first-class |
Windows (junctions via mklink /J) |
supported with a warning that junction targets are not portable |
| Bare repos / CI | works — falls back to materialized content when target unresolvable |
We considered it. The only existing attempt — Alcaro/GitBSLR — used
LD_PRELOAD to intercept syscalls. Linux-only, 96 stars over years, archived
2025. The fork-and-keep-up-with-master path leads to the same place. The
plugin-API path (this project) inherits git's release cadence for free and
costs nothing to maintain across git versions.
Pre-alpha. Spec captured, scaffolding in progress. First working pipeline target: dereference a real symlink in the canonical hooks dir scenario (see tests/hooks_scenario.md when it exists).
MIT.
Coded by Claude Opus 4.7 · Copyright 2026 Trenton Tompkins TrentTompkins@gmail.com · trentontompkins.com · tristate.digital · github.com/tibberous · (724) 431-5207