A command-line tool to install a link-farm to your dotfiles
Project description
The Basic Idea
Comparing dotfile managers? See ALTERNATIVES.md for a detailed comparison of dotx vs GNU Stow, chezmoi, YADM, dotbot, and others.
What dotx does; what it's for
The problem
You're a software developer with tons of dotfiles: .bashrc, .vimrc, .tmux.conf, .inputrc, and things living in
.config just to name a few. You have to install these on every system you work on and keep them up-to-date as
they change. Without some special setup, there's no one source of truth, no easy deploy, no version control. The
obvious answer is to keep them in a git repo, but making your home directory be that repo is no good. And maybe you
don't even want them to live together. Your bash files should be in a group, your vim files in a group, etc.
A solution
The solution is obvious: keep them in a git repo, divided up into packages (or multiple git repos if you prefer), and
install links in your home directory (or the target directory) that point into your git repo, at the source of
truth files.
The solution is so obvious in fact that of course there is already a perl tool called GNU stow that helps you do
exactly that. GNU stow has a feature that you can ask it to rename files that look like this: "dot-bashrc" to this
".bashrc". This is incredibly helpful if, for instance, you want to edit your dotfiles on your iPad and your editor
of choice can't see files or directories that start with a ".". Keeping your files in this form means no invisible
files in your source repo so you can edit anywhere with anything.
Unfortunately, GNU stow has a bug that its renaming feature doesn't work on directories. And it's also a very general
purpose tool. It's made for installing a link-farm to any kind of package from anywhere to anywhere.
dotx is a simple tool with a simple goal: manage a link-farm of possibly renamed links to dotfiles. Yes, you can use
it for other purposes, but it's tuned for its goal. It does the renaming task if you want it, but if your source files
are named simply .bashrc it works just as well.
How to install
It's an ordinary PyPI-supplied Python package providing CLI entry points, so you can install it with pip or any other tool you like. I think the best way to install it is this:
uv tool install dotx
The current version is: v3.2.0
The user interface
Usage: dotx [OPTIONS] COMMAND [ARGS]...
Manage a link farm: (un)install groups of links from "source packages".
Options:
--debug / --no-debug
--verbose / --quiet
--log FILE Where to write the log (defaults to stderr)
--target DIRECTORY Where to install (defaults to $HOME)
--dry-run / --no-dry-run Just echo; don't actually (un)install.
--help Show this message and exit.
Commands:
install install [source-package...]
uninstall uninstall [source-package...]
list List all installed packages
verify Verify installations against filesystem
show Show detailed installation information for a package
path Get source path of an installed package
which Find which package owns a target file
sync Rebuild database from filesystem (interactive)
So if you had a source package (a directory containing files) named "bash" containing "dot-bashrc" and
"dot-bash_profile" you could install links to those two files (named ".bashrc" and ".bash_profile") into your
${HOME} directory by being in the parent of the source package and saying:
+$ pwd
/Users/wolf/builds/dotfiles
+$ ls -1
bash
tmux
vim
+$ tree -aL 1 bash
bash
├── README.md
├── dot-bash_profile
├── dot-bash_tools.bin
├── dot-bash_topics.d
└── dot-bashrc
+$ dotx install bash
+$ ls -al ~
...
lrwxr--r-- 37 wolf 19 Jul 11:01 .bash_profile -> builds/dotfiles/bash/dot-bash_profile
lrwxr--r-- 39 wolf 19 Jul 11:01 .bash_tools.bin -> builds/dotfiles/bash/dot-bash_tools.bin/
lrwxr--r-- 38 wolf 19 Jul 11:01 .bash_topics.d -> builds/dotfiles/bash/dot-bash_topics.d/
lrwxr--r-- 31 wolf 19 Jul 11:01 .bashrc -> builds/dotfiles/bash/dot-bashrc
...
Ignoring files with .dotxignore
If you've got some files in your source package that don't need to be linked, you can create a .dotxignore file in
your package directory. This file works just like .gitignore and supports the same pattern syntax:
# In your package directory, create .dotxignore:
+$ cat > bash/.dotxignore <<EOF
README.*
.mypy_cache
*.tmp
EOF
+$ dotx install bash tmux vim
The .dotxignore file supports:
- Glob patterns (
*.log,*.tmp) - Directory patterns (
node_modules/,__pycache__/) - Negation patterns (
!important.conf) - Comments (lines starting with
#) - Root-only patterns (
/.gitmatches only at package root)
You can also nest .dotxignore files in subdirectories, just like .gitignore. Files closer to the matched file take
precedence.
Built-in ignore patterns
dotx comes with sensible defaults that automatically ignore common files you don't want installed to your home directory:
- Documentation:
README,README.*,*.md(for GitHub/docs, not installation) - Version control:
.git/,.gitignore,.svn/, etc. - Python artifacts:
__pycache__/,*.pyc,.pytest_cache/,.mypy_cache/ - Editor files:
.vscode/,.idea/,*.swp,*~ - OS files:
.DS_Store,Thumbs.db - Build artifacts:
dist/,build/
These patterns are always active and work alongside your custom .dotxignore files.
Escape hatch: If you do want a normally-ignored file installed, use negation patterns:
# In your package/.dotxignore
!important-notes.md
!INSTALL.md
Global ignore file
Create a global ignore file at ~/.config/dotx/ignore to exclude patterns from all packages:
+$ mkdir -p ~/.config/dotx
+$ cat > ~/.config/dotx/ignore <<EOF
# Ignore these in all packages
.DS_Store
.git/
*.log
*.tmp
__pycache__/
node_modules/
EOF
Uninstall looks almost just like install:
dotx uninstall bash vim tmux
Installation Database
dotx tracks all installations in a SQLite database at ~/.local/share/dotx/installed.db (or $XDG_DATA_HOME/dotx/installed.db). This enables better package management and verification.
List installed packages
See all installed packages with file counts and installation dates:
+$ dotx list
Installed Packages:
--------------------------------------------------------------------------------
Package Files Last Install
--------------------------------------------------------------------------------
/Users/wolf/builds/dotfiles/bash 12 2026-01-02 14:23:11
/Users/wolf/builds/dotfiles/tmux 3 2026-01-02 14:23:15
/Users/wolf/builds/dotfiles/vim 8 2026-01-02 14:23:18
--------------------------------------------------------------------------------
Total: 3 package(s)
Export as reinstall commands for easy migration:
+$ dotx list --as-commands
dotx install /Users/wolf/builds/dotfiles/bash
dotx install /Users/wolf/builds/dotfiles/tmux
dotx install /Users/wolf/builds/dotfiles/vim
Verify installations
Check that database records match the filesystem:
+$ dotx verify
✓ All installations verified successfully.
Or verify a specific package:
+$ dotx verify bash
/Users/wolf/builds/dotfiles/bash:
/Users/wolf/.bashrc
Issue: File missing (in DB but not on filesystem)
Expected type: file
⚠ Found 1 issue(s).
Show package details
View detailed information about a specific package:
+$ dotx show bash
Package: /Users/wolf/builds/dotfiles/bash
Installed files: 12
Installations:
--------------------------------------------------------------------------------
Target: /Users/wolf/.bash_profile
Type: file
When: 2026-01-02T14:23:11.234567
Target: /Users/wolf/.bashrc
Type: file
When: 2026-01-02T14:23:11.456789
Target: /Users/wolf/.bash_topics.d
Type: directory
When: 2026-01-02T14:23:11.678901
...
Get package source path
Get the source path of an installed package for composition with other Unix tools:
+$ dotx path bash
/Users/wolf/builds/dotfiles/bash
# Compose with other tools
+$ tree $(dotx path bash)
/Users/wolf/builds/dotfiles/bash
├── dot-bash_profile
├── dot-bash_topics.d
└── dot-bashrc
+$ cd $(dotx path vim)
+$ ls -la $(dotx path helix)
The command prints the source directory path(s) where package files are located. If a package has files from multiple source directories (like the shells example with subdirectories), all unique paths are printed, one per line.
Exit codes: 0 if package found, 1 if not found.
Find which package owns a file
Find which package installed a specific file:
+$ dotx which ~/.bashrc
bash
+$ dotx which ~/.config/helix/config.toml
helix
# Compose with other commands
+$ dotx path $(dotx which ~/.vimrc)
/Users/wolf/builds/dotfiles/vim
+$ tree $(dotx path $(dotx which ~/.zshrc))
Simple output (just the package name) for easy composition with other Unix tools.
Exit codes: 0 if file found, 1 if not managed by any package.
Rebuild database from filesystem
If you have existing dotfile installations and an empty or missing database, use sync to rebuild it.
The Problem: Unwanted Symlinks
By default, sync scans your home directory and ~/.config for all symlinks. This can discover symlinks you don't want to track as dotfiles packages, such as:
- System symlinks in
~/Library/(macOS) - Application symlinks in
/Applications/ - IDE or tool-generated symlinks
- Homebrew-managed symlinks
For example, without filtering you might see:
+$ dotx sync --dry-run
✓ Found 44 symlink(s)
Discovered 25 potential package(s):
/Users/wolf/dotfiles/bash # ✓ Want this
5 symlink(s)
/Users/wolf/dotfiles/vim # ✓ Want this
8 symlink(s)
/Users/wolf/Library # ✗ Don't want this!
1 symlink(s)
/Applications/Raycast.app/... # ✗ Don't want this!
1 symlink(s)
The Solution: --package-root
Use --package-root to filter packages to only those under specific directories. This is strongly recommended to avoid tracking unwanted symlinks:
# Filter to only your dotfiles directory
+$ dotx sync --dry-run --package-root ~/dotfiles
✓ Found 44 symlink(s)
Filtered out 6 symlink(s) not under --package-root
Discovered 3 potential package(s):
/Users/wolf/dotfiles/bash
5 symlink(s)
/Users/wolf/dotfiles/vim
8 symlink(s)
/Users/wolf/dotfiles/git
2 symlink(s)
You can specify multiple roots if your packages are in different locations:
+$ dotx sync --package-root ~/dotfiles --package-root ~/work/configs
Safety Warning
If you run sync without --package-root and have an empty database, you'll see a warning:
+$ dotx sync --dry-run
✓ Found 44 symlink(s)
⚠ Warning: No --package-root specified and database is empty.
Consider using --package-root to filter packages (e.g., --package-root ~/dotfiles)
Complete Example
# Preview what will be synced (recommended first step)
+$ dotx sync --dry-run --package-root ~/dotfiles
# After reviewing, sync for real
+$ dotx sync --package-root ~/dotfiles
✓ Found 15 symlink(s)
Filtered out 0 symlink(s) not under --package-root
Discovered 3 potential package(s):
/Users/wolf/dotfiles/bash
5 symlink(s)
/Users/wolf/dotfiles/vim
8 symlink(s)
/Users/wolf/dotfiles/git
2 symlink(s)
This will rebuild the database with the discovered installations.
Continue? [y/N]: y
✓ Recorded 15 installation(s) in database.
Note: The sync command is additive - it updates existing entries and adds new ones, but doesn't delete entries for packages not found. This means running sync with --package-root won't remove other packages from your database.
Shared Directories: How .config and Friends Work
When multiple packages need to install files into the same directory (like ~/.config), that directory must be a real directory, not a symlink. Otherwise, only one package could use it.
dotx automatically handles this using always-create patterns - directories that are always created as real directories instead of being symlinked.
Built-in Always-Create Patterns
These directories are automatically created as real directories:
XDG Base Directories:
.config- Application configuration files.local- User-local data.local/share- User-specific data files.local/bin- User-specific executables.cache- Non-essential cached data
Security-Sensitive Directories:
.ssh- SSH keys and configuration.gnupg- GPG keys and configuration
For example, if you have both a vim and helix package that install into .config:
+$ tree -L 2 dotfiles/
dotfiles/
├── vim/
│ └── dot-config/
│ └── nvim/
└── helix/
└── dot-config/
└── helix/
+$ dotx install vim helix
+$ ls -la ~/.config/
drwxr-xr-x .config/ # Real directory (not a symlink!)
lrwxr-xr-x nvim -> .../vim/dot-config/nvim/
lrwxr-xr-x helix -> .../helix/dot-config/helix/
Notice that .config itself is a real directory, allowing both packages to install their subdirectories as symlinks within it.
Custom Always-Create Patterns (Advanced)
For most users, the built-in patterns are sufficient. However, if you have custom shared directories, you can create .always-create files using the same syntax as .dotxignore:
Package-local (in your package directory):
+$ cat > mypackage/.always-create <<EOF
# Custom shared directory
.myapp
EOF
User-global (applies to all packages):
+$ mkdir -p ~/.config/dotx
+$ cat > ~/.config/dotx/always-create <<EOF
# My custom shared directories
.workspace
.tools
EOF
Pattern precedence: built-in → user global → package-local (later patterns can override earlier ones using !negation)
Note: Patterns use leading / for root-level matching (e.g., /.config matches only .config at package root, not subdir/.config).
Cleaning Orphaned Entries with --clean
Over time, your database may accumulate orphaned entries - records for symlinks that no longer exist on the filesystem. Use --clean to remove these automatically, similar to git fetch --prune:
# Preview what would be cleaned
+$ dotx sync --dry-run --clean --package-root ~/dotfiles
✓ Found 15 symlink(s)
Would clean orphaned entries:
bash: 2 orphaned entry(ies)
vim: 1 orphaned entry(ies)
Would remove 3 orphaned entry(ies).
Dry run - no database changes made.
# Clean for real
+$ dotx sync --clean --package-root ~/dotfiles
✓ Found 15 symlink(s)
Filtered out 0 symlink(s) not under --package-root
Discovered 3 potential package(s):
/Users/wolf/dotfiles/bash
5 symlink(s)
...
This will rebuild the database with the discovered installations.
Continue? [y/N]: y
✓ Recorded 15 installation(s) in database.
Cleaning orphaned entries...
✓ Removed 3 orphaned entry(ies).
When to use --clean:
- After manually removing symlinks
- After uninstalling packages without using
dotx uninstall - During regular maintenance to keep database accurate
- When migrating or reorganizing dotfiles
Important: --clean removes database entries for files that don't exist. Always preview with --dry-run first to ensure you're not removing entries you want to keep.
How it works
dotx uses a three-phase algorithm to safely install dotfiles:
Phase 1: Discovery (Search Down)
Starting from the source package root, dotx walks the directory tree top-down:
- Respects
.dotxignorerules - Skips files/directories matching patterns - Handles renaming - Detects
dot-prefixes (e.g.,dot-bashrc→.bashrc) - Builds a plan - Creates a
PlanNodefor each file/directory with:- Source and destination paths (accounting for renames)
- Whether it's a file or directory
- Initial action:
NONE(decided in next phase)
Top-down traversal is critical: parent directories must be processed before children so destination paths can be calculated correctly when parents are renamed.
Phase 2: Decision (Search Up)
With all paths discovered, dotx walks the tree bottom-up to decide what to do with each item:
For each directory, in bottom-up order:
-
Check children - Do any require renaming?
-
Check destination - Does it already exist?
-
Check patterns - Does it match an always-create pattern (like
.config)? -
Decide action:
- EXISTS - Directory already exists at destination (merge into it)
- CREATE - Must make real directory because:
- Children need renaming, OR
- Matches always-create pattern (e.g.,
.configmust be real so multiple packages can use it)
- LINK - Can symlink the whole directory (no conflicts, no special rules)
- FAIL - Would overwrite an existing regular file
- SKIP - Parent will be linked, so children need no action
-
Update ancestors - If creating a directory, mark parent directories for creation up to one that already exists
Bottom-up traversal ensures we know about all children before deciding what to do with their parent.
Phase 3: Execution
Finally, dotx executes the plan in top-down order:
- CREATE - Make real directories with
Path.mkdir() - LINK - Create symlinks with
Path.symlink_to() - SKIP/EXISTS - No action needed
- FAIL - Report conflict and abort
All operations use Python's pathlib API for cross-platform compatibility. In dry-run mode, equivalent shell commands (like ln -s) are displayed to show what would happen.
Transactional Guarantees
When installing multiple packages, dotx provides all-or-nothing semantics:
-
Pre-flight validation - Plans are built for ALL packages and checked for conflicts BEFORE any filesystem changes are made. If ANY package would fail (e.g., would overwrite an existing file), the entire operation is aborted with no changes.
-
Database transaction - All installation records are written within a SQLite transaction. The database context manager commits on success or rolls back on any exception, ensuring the database remains consistent even if execution is interrupted.
-
Filesystem execution - Once validation passes, filesystem changes (creating directories, making symlinks) proceed in sequence. While filesystem operations themselves aren't transactional (you can't "undo" a created directory), the pre-flight validation minimizes risk by catching conflicts before any changes are made.
This design ensures that either all packages install successfully, or none do—you won't end up with a partially-installed package or an inconsistent database.
Best-Effort Installation
Question: What if I want to install multiple packages but continue even if some fail?
Answer: Use a shell loop instead of passing all packages at once:
# Install what you can, report failures
for pkg in bash vim tmux git helix; do
dotx install ~/dotfiles/$pkg || echo "✗ Failed to install $pkg"
done
# Or reinstall from database, skipping failures
dotx list --as-commands | while read cmd; do
$cmd || true
done
Why doesn't dotx have a --force or --best-effort flag?
The all-or-nothing transactional guarantee is intentional. When dotx install succeeds, you know exactly what state you're in. Best-effort mode would trade that clarity for convenience you can already get with a one-line shell loop.
This follows the Unix philosophy: let dotx do one thing well (transactional package installation), and let the shell handle control flow. Your shell script can implement any retry/continue-on-error logic that fits your specific needs.
Changelog
See CHANGELOG.md for version history and release notes.
Development
Running Tests
dotx uses pytest for testing with coverage tracking:
# Run all tests with coverage report
pytest
# Run specific test file
pytest tests/test_install.py
# Run tests without coverage
pytest --no-cov
# View detailed HTML coverage report
pytest && open htmlcov/index.html
Current test coverage is tracked and reported automatically. The coverage report shows:
- Line coverage percentage for each module
- Branch coverage for conditional statements
- Missing lines that need test coverage
Pre-commit Hooks
The project uses pre-commit hooks to ensure code quality:
# Install hooks (one-time setup)
pre-commit install
# Run hooks manually on all files
pre-commit run --all-files
Hooks include:
- ruff: Linting and formatting
- pyrefly: Type checking
- pytest: All tests must pass
Integration Testing
Tests in tests/test_cli.py use CliRunner from typer.testing to perform integration testing of the CLI. These tests run the entire CLI app in-process, verifying that commands work end-to-end with real filesystem operations.
Clean Development Artifacts
Remove build, test, and coverage artifacts:
# Preview what will be removed (dry run)
git clean -fdxn
# Remove all development artifacts
git clean -fdx
This removes:
.venv/- Virtual environment.coverage- Coverage data filehtmlcov/- HTML coverage reports.pytest_cache/- Pytest cache__pycache__/- Python bytecode cache*.egg-info/- Package metadatadist/- Build distributions
Note: Add . at the end (git clean -fdx .) to limit cleanup to the current directory only.
Shell Completions
dotx includes automatic shell completion via Typer:
# Install completion for your shell
dotx --install-completion
# Or show the completion script to customize it
dotx --show-completion
Supports Bash, Zsh, Fish, and PowerShell automatically.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dotx-3.3.2.tar.gz.
File metadata
- Download URL: dotx-3.3.2.tar.gz
- Upload date:
- Size: 61.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.25 {"installer":{"name":"uv","version":"0.9.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
65d4a78fbb387edce031ed275f00dbf91c23559c8a2f7992c5d60c796d8a0f41
|
|
| MD5 |
b5c5c461b540d03cbac17eabcddeb71c
|
|
| BLAKE2b-256 |
541f96661fbb958ad6296c574190412d33c0f1040b1dbadfd9533d6686837d80
|
File details
Details for the file dotx-3.3.2-py3-none-any.whl.
File metadata
- Download URL: dotx-3.3.2-py3-none-any.whl
- Upload date:
- Size: 44.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.25 {"installer":{"name":"uv","version":"0.9.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
600da9a6596c657dfc5bbebc35f2943f60ecdc411ec26da7373e9fe87bcdfc4f
|
|
| MD5 |
2ce9b60cb5a86607d7dff345496352be
|
|
| BLAKE2b-256 |
96d1a89e7bb92211f4d547c682074f923a5a4eca303a902c1074376e91a02521
|