- Extract FileStore port and InMemoryFileStore/FileStoreImpl/Reporter adapters - Add Reporter trait to notcore with TerminalReporter and JsonReporter - Refactor linker, package, status modules to use FileStore port - Add integration test suite with dotfiles fixtures - Add cross-crate integration tests - Remove ssh_rsa identity/recipient (unsupported) - Add rustqual.toml, .sccignore, notfiles.toml config files - Update CLAUDE.md, README.md, AGENTS.md with current architecture
8.4 KiB
notfiles
A pure-Rust dotfiles manager — and eventually, a complete new-machine bootstrap system.
notfiles started as a Rust replacement for GNU Stow. It's growing into a Cargo workspace of focused crates that together replace an entire shell-script-based dotfiles ecosystem.
Quick Start
# Symlink your dotfiles
notfiles link
# Check status
notfiles status
# Validate config without changes (CI-friendly)
notfiles check
# Show copy-method divergence
notfiles diff
# Move an existing file into a package
notfiles adopt git .gitconfig
# Remove symlinks
notfiles unlink
# Generate shell completions
notfiles completions bash > ~/.bash_completion.d/notfiles
All commands support --dry-run, --verbose, and --json flags.
On a new machine (once notstrap ships):
cargo install notstrap
notstrap run
Workspace Architecture
notfiles/
├── crates/
│ ├── notcore/ # shared types, config, paths, errors, Reporter trait
│ ├── notfiles/ # symlink engine (stow replacement) ← you are here
│ ├── notsecrets/ # multi-provider secret resolution + age crypto
│ ├── nothooks/ # hook execution engine
│ ├── notnet/ # network utilities (Tailscale, Yubikey)
│ ├── notstrap/ # new-machine bootstrap orchestrator
│ └── notgraph/ # Rust dependency graph visualizer
├── notfiles.toml # symlink package config
└── notstrap.toml # bootstrap hook/phase config
Crate responsibilities
| Crate | Type | Does |
|---|---|---|
notcore |
lib | Shared types, config, paths, errors, Reporter trait — no deps on other crates |
notfiles |
lib + bin | Symlink/copy packages into $HOME, track state, adopt/diff/check |
notsecrets |
lib | Multi-provider secret resolution, native age encryption/decryption |
nothooks |
lib + bin | Run bootstrap hooks in phases, skip already-run setup hooks |
notnet |
lib | Tailscale integration, Yubikey identity source |
notstrap |
bin | Orchestrate everything on a fresh machine |
notgraph |
lib + bin | Rust file dependency/import graph with HTML/Mermaid output |
Dependency graph
notstrap
├── notfiles
│ └── notcore
├── notsecrets
│ └── notcore
├── notnet
└── nothooks
└── notcore
notcore is the only shared dependency. No circular deps.
How notfiles Works
Each subdirectory of your dotfiles repo is a package. notfiles link walks each package and symlinks its contents into a target directory (default: $HOME), mirroring the directory structure.
dotfiles/
└── zsh/
└── .zshrc → symlink → ~/.zshrc
dotfiles/
└── git/
└── .config/
└── git/
└── config → symlink → ~/.config/git/config
State is tracked in .notfiles-state.toml so unlink and status know exactly what was linked, when, and how.
Link flow
notfiles link
│
├─ config.validate() check include/exclude mutual exclusion
│
├─ resolve_packages_filtered() include/exclude + platform filtering
│
├─ collect_files() recursive walk, apply ignore patterns
│
├─ conflict_check() existing file? symlink to wrong target?
│
└─ linker::link_package() create symlinks (or copies), write state,
return LinkResult with counters
State file
.notfiles-state.toml records every linked file:
[[entries]]
source = "/Users/joe/dotfiles/zsh/.zshrc"
target = "/Users/joe/.zshrc"
method = "symlink"
package = "zsh"
linked_at = "2026-03-31T10:00:00Z"
This powers status (diff expected vs actual) and unlink (clean removal with empty-parent cleanup).
New Machine Bootstrap (notstrap)
The hardest part of a new machine is the chicken-and-egg problem: you need secrets to set up the machine, but secrets live in an encrypted file that requires a key you haven't retrieved yet.
notstrap solves this with a staged bootstrap:
notstrap run
│
├─ 1. Prerequisites check
│ Is bw/sops/age available? Print exactly what's missing and stop.
│
├─ 2. notsecrets — retrieve age key
│ ├─ try: Bitwarden CLI (bw unlock)
│ ├─ fallback: --key-file <path> (USB drive)
│ └─ fallback: interactive prompt (paste key)
│ └─ sops decrypt secrets.sops.env → env injected
│ (now op, bw, github, openai, anthropic tokens are live)
│
├─ 3. Clone dotfiles repo (if not present)
│
├─ 4. notfiles link — stow all packages
│
├─ 5. nothooks --phase dot
│ shell config, git config, AI tool configs (~seconds, re-runnable)
│
├─ 6. nothooks --phase setup
│ Homebrew/Nix packages, mise runtimes, dev tools, op install (~minutes, once)
│
└─ 7. Report
✓ linked 142 files ✓ 3 dot hooks ✓ 7 setup hooks
Note: 1Password (op) is installed as a hook in phase setup — after secrets are already available via sops. It takes over secret management for day-to-day use once the machine is live.
Secrets Bootstrap Detail
notsecrets implements a SecretResolver with pluggable provider sources:
SecretResolver
├── EnvSource read from environment variables
├── OpSource 1Password CLI (op read)
├── BitwardenSource bw CLI
├── FileSource read from file path
├── DotenvxSource dotenvx encrypted .env files
├── SopsSource SOPS-encrypted files (native age decryption)
└── ... (extensible via SecretSource trait)
Age encryption/decryption is handled natively (no external age or sops
binaries). Supports x25519, SSH ed25519/RSA, and scrypt identities.
Hook Phases
nothooks runs hooks in two phases defined in notstrap.toml:
| Phase | Speed | Re-runnable | Examples |
|---|---|---|---|
dot |
~seconds | Yes | shell config, git config, AI tool configs |
setup |
~minutes | No (tracked) | Homebrew packages, mise runtimes, op install |
setup hooks are tracked in .nothooks-state.toml — already-run hooks are skipped unless --force is passed. dot hooks always re-run (they're idempotent by design).
Configuration
notfiles.toml — symlink config
[defaults]
method = "symlink"
target = "~"
include = ["git", "zsh", "nushell", "starship"] # allowlist (optional)
# exclude = ["scratch"] # or blocklist (mutually exclusive)
[packages.secrets]
method = "copy" # copy instead of symlink for sensitive files
[packages.nixos]
platforms = ["linux"] # only link on Linux
[packages.work]
target = "~/work" # different target dir
ignore = ["*.local"]
notstrap.toml — bootstrap config
[bootstrap]
dotfiles_repo = "git@github.com:you/dotfiles.git"
dotfiles_dir = "~/dotfiles"
[[hooks]]
name = "shell-config"
script = "scripts/setup-git-config.sh"
phase = "dot"
[[hooks]]
name = "homebrew-packages"
script = "scripts/setup-packages.sh"
phase = "setup"
Migration from dotfiles/
Migration is gradual — shell scripts are replaced as Rust equivalents ship:
| Phase | Ships | Replaces |
|---|---|---|
| 1 | notfiles workspace (now) |
GNU Stow |
| 2 | notsecrets + notstrap skeleton |
setup-secrets.sh, install.sh |
| 3 | nothooks |
bootstrap.sh hook runner |
| 4 | Hook-by-hook Rust rewrites | Individual setup-*.sh scripts |
| 5 | dotfiles/ archived |
— |
Development
cargo build # build all crates
cargo test # run all tests
cargo test -p notfiles # test one crate
cargo clippy --workspace # lint everything
cargo fmt --check # format check
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.