Skip to content

Symlinks Manager

Deploys this repo into $HOME: a common base, plus one <axis>/<value> directory per coordinate axis the machine sits on. dotfiles symlinks --help lists the verbs, and dotfiles machines show prints the point a machine sits at.

Two of the three trees do not mirror into $HOME

configs/ mirrors into $HOME directly. apps/ lands in ~/.local/bin/. shell/ lands in ~/.local/shell/ — shell code, functions and aliases, rather than config, which is why it does not sit under ~/.config. TREES in src/dotfiles/resources/symlinks.py is the declaration, and its docstring says why two of them flatten and one keeps its axis path.

An app whose job is to change the calling shell cannot live in apps/ at all. A symlinked command runs in its own process. It cannot export into the shell that ran it, so it becomes a function in shell/common/functions.sh instead.

Go apps, and personal CLI tools like theme and font, are not symlinked. They have their own installers — see App Installation Patterns.

The trees mean two opposite things by their coordinate directories

configs/ and apps/ hold variants. They flatten onto the destination, so exactly one file arrives. The rest are versions this machine did not select.

shell/ holds layers. Every directory this machine's coordinates select is sourced together, and each keeps its <axis>/<value> path so a sourced file says which coordinate asked for it.

Two variants may never claim one target

Deployment is ordered, so a collision does not fail. It deploys whichever directory comes later and reports success, which reads as an override rather than as the bug it is. Nothing at run time can catch that, which is why tests/symlinks/test_coordinate_directories.py asserts it against every machine the axes can express rather than only the ones a manifest names today.

Nothing is unlinked before being rewritten

A link already deployed produces no change at all, so apply leaves it untouched. That closes the window a daemon watching its own config regenerates a default inside. Hyprland found its file gone mid-pass, wrote itself a default, and the create pass then refused the target as foreign. Never reinstate a pass that unlinks everything first — the window is closed by deciding per link, not by ordering two passes. tests/resources/test_symlinks.py::test_a_deployed_config_is_never_touched_by_a_later_run pins it.

A copy machine gives up provenance, and everything built on it

A machine whose manifest sets deploy_by_copy: true gets the same three trees at the same destinations, written as regular files. A copy is a regular file, so ownership can only ever answer foreign for one. Provenance is what both target refusals and the orphan prune are built out of, so all three go together: --force is refused at the door, and nothing is ever pruned. A file at a path the repo no longer declares stays where it is.

That last one is deliberate rather than a gap. Nothing on disk distinguishes a copy this manager wrote from a file somebody put there, and the Windows box is where the only copy of a file this repo cannot regenerate is most likely to be sitting.

dotfiles symlinks unlink survives the loss by asking a different question. Pruning has to ask about a path nothing declares, which is the question a copy cannot answer. Unlinking asks only about paths the repo names, and every path this mechanism has ever written is one of them.

The module docstring in src/dotfiles/resources/symlinks.py carries the rest of what copy retires and why each was given up in the open. install/manifests/windows-work-workstation.yml says why the boolean is declared rather than derived from os_family.

A template carries a per-machine value into a file that cannot read one

A file ending in .tmpl deploys without the suffix, as a regular file with its ${NAME} placeholders filled from ~/.env. It is for a program that reads its one config file in every process, such as atuin. A value the program takes from its environment reaches only processes that sourced ~/.env, and an ssh host cmd never does. src/dotfiles/symlinks/template.py holds the syntax and the write.

Every placeholder is a required: entry in install/flags.yml. dotfiles machines check fails a template whose placeholder some machine selecting it is never asked for. On that machine only the template's own row would name the value. The env check and the generated block in ~/.env list only the machine's required: entries. An unset value is named twice by dotfiles check: on the env row and on the template's. apply leaves the target as it found it.

A rendered file is a regular file, so the template stands in for the provenance a link carries. A target matching the template is this manager's earlier rendering, whatever stands where a placeholder sits. apply renders over it and dotfiles symlinks unlink removes it. Anything else is somebody's file, and apply refuses it like a foreign link unless given --force. A copy machine refuses it too, because the template can tell the two apart where a copy cannot.

Rejected: a templates/ tree with a resource of its own. It is a second walk with a second collision domain and a new CLI noun, and one file's variants would sit in two trees: the atuin config's nonfleet variant is a plain file under configs/. Rejected: managed_files in install/system.yml, which writes root-owned files with escalation and fills only {user}. Rejected: keeping the file hand-written and checking only that it names an address, which lets every other setting in it drift between machines. Rejected: a linked config naming no address, leaving atuin to read ATUIN_SYNC_ADDRESS itself. An interactive shell then syncs to the right server, while ssh host atuin doctor on the same machine reports atuin's hosted service.

plan finds them and apply repairs them by pruning, and both touch only links resolving into this repo. A find -delete sweep across $HOME deletes every broken link on the machine, including ones this manager never created and is not responsible for.

~/.local/shell/local.sh is the file that proves the boundary matters. It is a real file that no repo declares, holding machine-local shell code that exists nowhere else. It survives every deploy because only a link resolving into the source tree is ever unlinked.

The exclusion rules carry their constraints beside the constants

EXCLUDE_PATTERNS decides what never gets linked, and path handling around it has failed in two ways that are easy to reintroduce. Substring matching on .git/ excluded .gitconfig. Hand-rolled relative paths broke 122 symlinks in one pass. Both are worked through with the failing code and the regression assertions in Learnings: Symlinks Path Gotchas.

EXCLUDE_SEARCH_DIRS is a separate list governing where the orphan walk descends rather than what gets linked, and SEARCH_DEPTH bounds how far down it goes. Both carry their reasoning and their rejected alternatives in comments beside them in src/dotfiles/symlinks/core.py, where anyone about to change a value is already reading.

Deciding and acting live in separate modules

src/dotfiles/resources/symlinks.py decides: what the repo declares, what is at each target, and what to do about the difference. src/dotfiles/symlinks/core.py holds the primitives it decides with. src/dotfiles/deploy.py is the epilogue that runs after a deploy.

Decisions are tested in tests/resources/test_symlinks.py, which builds a whole synthetic repo and home per test. The primitives are covered in tests/symlinks/.