Skip to main content

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 (/.git matches 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:

  1. Respects .dotxignore rules - Skips files/directories matching patterns
  2. Handles renaming - Detects dot- prefixes (e.g., dot-bashrc.bashrc)
  3. Builds a plan - Creates a PlanNode for 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:

  1. Check children - Do any require renaming?

  2. Check destination - Does it already exist?

  3. Check patterns - Does it match an always-create pattern (like .config)?

  4. 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., .config must 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
  5. 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:

  1. 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.

  2. 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.

  3. 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 file
  • htmlcov/ - HTML coverage reports
  • .pytest_cache/ - Pytest cache
  • __pycache__/ - Python bytecode cache
  • *.egg-info/ - Package metadata
  • dist/ - 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

dotx-3.3.2.tar.gz (61.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

dotx-3.3.2-py3-none-any.whl (44.1 kB view details)

Uploaded Python 3

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

Hashes for dotx-3.3.2.tar.gz
Algorithm Hash digest
SHA256 65d4a78fbb387edce031ed275f00dbf91c23559c8a2f7992c5d60c796d8a0f41
MD5 b5c5c461b540d03cbac17eabcddeb71c
BLAKE2b-256 541f96661fbb958ad6296c574190412d33c0f1040b1dbadfd9533d6686837d80

See more details on using hashes here.

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

Hashes for dotx-3.3.2-py3-none-any.whl
Algorithm Hash digest
SHA256 600da9a6596c657dfc5bbebc35f2943f60ecdc411ec26da7373e9fe87bcdfc4f
MD5 2ce9b60cb5a86607d7dff345496352be
BLAKE2b-256 96d1a89e7bb92211f4d547c682074f923a5a4eca303a902c1074376e91a02521

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page