How to Build a Cross-Shell Dotfiles Repository
Separate shared environment, shell-specific syntax, secrets, and machine-local state, then deploy links idempotently and make rollback intentional.
A useful dotfiles repository does not force Bash, Zsh, Fish, and other shells to parse one supposedly universal file. It shares data where syntax is genuinely compatible and keeps each shell’s interactive behavior in its own layer.
Choose a predictable layout
dotfiles/
bin/
config/git/config
shell/env.sh
bash/bashrc
zsh/zshrc
install.sh
shell/env.sh can contain conservative POSIX-compatible exports used by Bash and Zsh. Aliases, completion systems, prompt hooks, arrays, and option syntax belong in their respective shell directories. Fish should use native .fish configuration rather than sourcing POSIX shell code.
Keep secrets and machine facts out of Git
Commit an example local file, then source a real untracked file only when present:
if [ -r "$HOME/.config/shell/local.sh" ]; then
. "$HOME/.config/shell/local.sh"
fi
Even a private repository is a poor secret manager. Hostnames, work-only paths, tokens, and SDK locations change at a different cadence than portable preferences.
Link safely and idempotently
An installer should create parent directories, refuse to overwrite an unrelated existing file, and treat an already-correct symlink as success. Before replacing anything, move it to a timestamped backup location and print exactly what changed. Never recursively link the repository over $HOME.
Tools such as GNU Stow can model directory trees as symlink packages, but they do not remove the need to preview conflicts and understand the target layout. A small explicit installer is often easier to audit than a framework with implicit behavior.
Test without risking the real home directory
Create a temporary home or disposable container, run installation twice, launch each supported shell non-interactively and interactively, and verify uninstall or rollback. CI can at least parse the shell files and run ShellCheck; local tests should confirm completion and prompt behavior in a real terminal.
Pinning every preference across every machine can become its own failure mode. Treat dotfiles as maintainable configuration: documented entry points, small components, local overrides, and a recovery path when a new edit prevents the shell from starting.
Why Stow’s specific design choice matters for dotfiles specifically
GNU Stow’s own design was a deliberate reaction to an earlier tool called Depot, developed at Carnegie Mellon for managing software package installations — Depot tracked installed state in a separate database file, and Stow’s creator specifically chose not to do that, storing no extra state between runs at all. That stateless design is exactly why Stow suits a dotfiles repository well: the symlinks it creates ARE the entire state, directly inspectable with ls -la or readlink, with nothing hidden in a separate tracking file that could drift out of sync with what’s actually on disk. A hand-written installer following the same no-hidden-state principle, as this guide’s own “small explicit installer” alternative suggests, inherits that same auditability even without depending on Stow itself.
Making the untracked-file convention actually enforced, not just documented
Documenting “don’t commit local.sh” in a README is easy to forget under deadline pressure — add the actual filename to .gitignore so Git itself refuses to stage it accidentally, and separately confirm with git status after any broad git add that nothing matching your local-secrets pattern actually got included. A secrets scanner run as a pre-commit hook (checking for common token and key patterns before a commit is even created) adds a second, automated layer of protection beyond .gitignore alone, which only prevents accidental staging of a specifically-named file, not a secret pasted directly into a file that IS tracked.
Handling the same dotfiles repository across genuinely different operating systems
If your dotfiles need to work across Linux, macOS, and WSL specifically, isolate OS-specific differences (different default package manager, different path conventions, macOS’s BSD-flavored core utilities versus GNU coreutils on Linux) behind a single, explicit detection point early in your shared environment file, rather than scattering uname-based conditionals throughout many separate files. Centralizing that detection in one place makes it considerably easier to see, at a glance, everywhere your configuration actually depends on which operating system it’s running under.
When a symlink farm genuinely isn’t enough: templating and secrets
Stow (and a hand-written installer following the same idea) both assume the file content itself is identical everywhere — the only thing that varies per machine is whether a given file gets linked at all. That assumption breaks down the moment a config needs to differ by hostname, OS, or contain a value that shouldn’t be committed in plaintext at all. Chezmoi, a newer and more elaborate dotfiles manager, addresses exactly that gap: rather than symlinking, it renders templated source files into real files in $HOME on chezmoi apply, with Go template syntax available to branch on the current machine’s OS or hostname and pull secrets from an external password manager at apply time instead of storing them in the repository. That power comes at a real cost in complexity — a plain Stow-style symlink farm is directly inspectable with ls -la, while a templated chezmoi source needs chezmoi apply --dry-run or similar to see what it would actually produce. Reach for templating only once per-machine variation or secrets genuinely require it; a repository that’s the same everywhere has no reason to take on that complexity.
Related:
- The History of the Unix Shell: From Thompson Shell to Bash and Zsh
- How Shell Prompt Customization Actually Works
Sources: