Use git to manage dotfiles without symlinks. This setup uses a dedicated git dir at ~/git/dotfiles with work-tree ~ (via the dotfiles wrapper). Uses nix-darwin (macOS) or home-manager (Linux) and brew (macOS) to set up and install software, and mise to manage runtimes.
Quick start:
curl -fsSL https://raw.githubusercontent.com/connorads/dotfiles/master/install.sh | bashβ bootstraps macOS, Linux, or Codespaces.
β οΈ Tailored to my specific machines (usernamesconnor/connoradsand a handful of named host configs). Forking? See Setup below for what to change (flake.nixconfigs + theVALID_DARWIN/VALID_HMlists ininstall.sh).
- No symlinks: tracked files live directly in
$HOME. - Git metadata stays out of the way in
~/git/dotfiles. - Bootstrap is safer on existing machines where dotfiles may already exist.
- Day-to-day Git UX stays reliable, including ahead-behind and push state in LazyGit.
Under the hood, git metadata is stored at ~/git/dotfiles, and core.worktree points at $HOME.
If you've already got your dotfiles setup you can use the following commands to manage your dotfiles.
First un-ignore the file/path in ~/.gitignore, then add it:
dotfiles add .somefiledotfiles rm --cached .somefileDotfiles use hk for fast staged-file checks on commit.
dotfiles config core.hooksPath .hk-hooks
mise install
dhk checkdotfiles commit then runs .hk-hooks/pre-commit, which calls hk run pre-commit.
For shell-function regression tests:
mise run zsh-testsBuild and activate nix-darwin config. This will make changes to the system and update packages as per flake.nix
darwin-rebuild switch --flake ~/.config/nix
# alias: drsUpdate everything: bump mise.lock + flake.lock (committing each), upgrade brew, then rebuild.
up
# up -s / up --frozen # frozen: install committed locks only, no bumps/brew/flakeup is the canonical updater; see AGENTS.md for the lockfile-commit posture and supply-chain quarantine it enforces. The underlying steps (nfu for flake.lock, brew upgrade, mise upgrade) can still be run individually.
Build and activate home-manager config. This will update packages as per flake.nix
home-manager switch --flake ~/.config/nix
# alias: hmsUpdate everything: bump mise.lock + flake.lock (committing each), then rebuild (nrs + hms on NixOS).
up
# up -s / up --frozen # frozen: install committed locks only, no bumps/flakeup is the canonical updater; see AGENTS.md for the lockfile-commit posture and supply-chain quarantine it enforces. The underlying steps (nfu for flake.lock, mise upgrade) can still be run individually.
If you are setting up this exact repo on macOS, Linux, or Codespaces, use the bootstrap script:
curl -fsSL https://raw.githubusercontent.com/connorads/dotfiles/master/install.sh | bashIt installs dotfiles and sets upstream tracking so git status/LazyGit show ahead-behind correctly.
Fresh machine β selecting the host config. The script activates a specific config
(nix-darwin on macOS, home-manager on Linux), normally resolved from the machine's
hostname. On a freshly reset/provisioned box the hostname rarely matches yet, so:
- Set the override env var to pick explicitly:
- macOS:
DARWIN_HOST=Connors-Mac-mini(orConnors-MacBook-Air) - Linux:
HM_HOST=dev(orpenguin/rpi5)
- macOS:
- β¦or answer the interactive prompt. For the prompt to get a terminal, prefer
bash <(curl -fsSL β¦/install.sh)or download-then-run over a barecurl β¦ | bashpipe. - Picking the wrong host is refused rather than silently applied; an unknown/undecidable host fails loudly with the valid list.
The first activation passes the config explicitly (--flake β¦#<attr>) and then converges
the hostname (macOS via networking.hostName, Linux via hostnamectl). After that, bare
drs/hms/up resolve the right config from the hostname with no #attr needed.
The valid host names are hardcoded in install.sh (VALID_DARWIN / VALID_HM) β keep them
in sync with flake.nix if you add or rename a config.
If you want to follow the manual path (or fork this repo), use this.
-
Clone using a separate git dir
DOTFILES_REPO=https://github.com/connorads/dotfiles.git DOTFILES_DIR=$HOME/git/dotfiles BOOTSTRAP_WORKTREE=$(mktemp -d "$HOME/.dotfiles-bootstrap.XXXXXX") git clone --separate-git-dir="$DOTFILES_DIR" "$DOTFILES_REPO" "$BOOTSTRAP_WORKTREE" rm -rf "$BOOTSTRAP_WORKTREE"
Why the temporary
BOOTSTRAP_WORKTREEdir?git cloneneeds a checkout target path, and$HOMEis non-empty. The temp dir keeps bootstrap safe and disposable. -
Point the repo at
$HOMEand ensure tracking refsgit --git-dir="$DOTFILES_DIR" config core.bare false git --git-dir="$DOTFILES_DIR" config core.worktree "$HOME" git --git-dir="$DOTFILES_DIR" config --replace-all remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" git --git-dir="$DOTFILES_DIR" fetch origin --prune
-
Check out dotfiles into
$HOME(β οΈ this overwrites conflicting files)git --git-dir="$DOTFILES_DIR" checkout 2>&1 | sed -n 's/^[[:space:]]\+//p' | while IFS= read -r file; do if [ -f "$file" ]; then mv "$file" "$file.bak" fi done git --git-dir="$DOTFILES_DIR" --work-tree="$HOME" checkout -f
-
Set upstream for the current branch
CURRENT_BRANCH=$(git --git-dir="$DOTFILES_DIR" symbolic-ref --quiet --short HEAD) git --git-dir="$DOTFILES_DIR" --work-tree="$HOME" branch --set-upstream-to="origin/$CURRENT_BRANCH" "$CURRENT_BRANCH"
-
Set up nix, brew and install software
macOS (nix-darwin):
# Install Homebrew /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" eval "$(/opt/homebrew/bin/brew shellenv)" # Install Nix (vanilla, not Determinate Nix) curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install --determinate false . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh # Build and activate nix-darwin configuration nix run nix-darwin/master#darwin-rebuild -- switch --flake ~/.config/nix
Linux (home-manager):
# Install Nix (vanilla, not Determinate Nix) curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install --determinate false . ~/.nix-profile/etc/profile.d/nix.sh # Build and activate home-manager configuration nix run home-manager/master -- switch --flake ~/.config/nix
-
Reload your shell
exec zsh
If an existing machine has an older setup, run:
DOTFILES_DIR=$HOME/git/dotfiles
if [ "$(git --git-dir=$DOTFILES_DIR rev-parse --is-bare-repository 2>/dev/null || true)" = "true" ]; then
git --git-dir=$DOTFILES_DIR config --unset core.bare || true
fi
git --git-dir=$DOTFILES_DIR config core.worktree $HOME
CURRENT_BRANCH=$(git --git-dir=$DOTFILES_DIR/ symbolic-ref --quiet --short HEAD)
git --git-dir=$DOTFILES_DIR/ config --replace-all remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
git --git-dir=$DOTFILES_DIR/ fetch origin --prune
git --git-dir=$DOTFILES_DIR/ --work-tree=$HOME branch --set-upstream-to=origin/$CURRENT_BRANCH $CURRENT_BRANCHThis section is for anyone who wants to build their own dotfiles repo using the same git-dir + work-tree technique (not specifically this repo).
Show from-scratch setup
-
Create the git dir and point work-tree at
$HOMEDOTFILES_DIR=$HOME/git/dotfiles mkdir -p "$DOTFILES_DIR" git init "$DOTFILES_DIR" git --git-dir="$DOTFILES_DIR" config core.worktree "$HOME" git --git-dir="$DOTFILES_DIR" config --replace-all remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
-
Add a safe default ignore policy (ignore everything, then un-ignore specific files)
touch "$HOME/.gitignore" grep -qxF '/*' "$HOME/.gitignore" || printf '%s\n' '/*' >> "$HOME/.gitignore" grep -qxF '!.gitignore' "$HOME/.gitignore" || printf '%s\n' '!.gitignore' >> "$HOME/.gitignore"
-
Start tracking files by un-ignoring paths in
~/.gitignore, then adding themgit --git-dir="$DOTFILES_DIR" --work-tree="$HOME" add .gitignore git --git-dir="$DOTFILES_DIR" --work-tree="$HOME" commit -m "chore(dotfiles): initialise from scratch"
-
Optional: connect a remote and push
DOTFILES_REPO=git@github.com:your-user/dotfiles.git dotfiles remote add origin "$DOTFILES_REPO" dotfiles push -u origin HEADGit also supports separate fetch and push URLs for the same remote. This is useful when a clone should pull over HTTPS but only push through SSH:
dotfiles remote set-url origin https://github.com/your-user/dotfiles.git dotfiles remote set-url --push origin git@github.com:your-user/dotfiles.git
After this,
dotfiles pulluses HTTPS anddotfiles pushuses SSH. This can be handy on machines where read-only updates should not depend on SSH agent forwarding, while write access still uses the normal SSH path.
macOS sudo auth is configured in darwin-desktop.nix:
- Touch ID is enabled for both Macs
pam_reattachis enabled so Touch ID works insidetmuxpam_u2fremains in the sudo PAM stack for YubiKey auth
In practice:
- MacBook Air uses Touch ID first, with YubiKey as fallback
- Mac mini falls through to YubiKey unless it has a Touch ID-capable keyboard
The YubiKey mapping file is local per machine and is not tracked in dotfiles:
mkdir ~/.config/Yubico
pamu2fcfg > ~/.config/Yubico/u2f_keysAdd a second key if you like
pamu2fcfg -n >> ~/.config/Yubico/u2f_keysInspired by
- StreakyCobra's comment on Hacker News for idea to avoid symlinks with bare repo
- zwyx's blog post for Sublime Merge integration (historical reference; I now use LazyGit day-to-day)
- Using a YubiKey (or other security key) for sudo via pam