..
My dotfiles — what's in ~/.dotfiles and why
I keep everything that customises my machine inside a single folder: ~/.dotfiles. Nothing in there is exotic, but every file is the result of “I got annoyed once and fixed it.” This post is mostly a tour, written so future-me remembers why the settings are what they are.
~/.dotfiles
├── .gitconfig
├── .gitignore
├── .ssh
├── .tmux.conf
├── zshrc
├── vimrc/
│ ├── idea/ # IdeaVim config
│ └── nvim/ # Neovim config (symlinked into ~/.config/nvim)
└── .config/
├── alacritty/
├── wezterm/
├── karabiner/
├── coc/
└── Code/
Two important details about the layout:
- The folder is a git repo (note the
.gitand.gitignore). That’s the whole point — I can clone it on a new machine and symlink everything into$HOME. .config/nvimis a symlink back tovimrc/nvim. That way I can keep all my editor configs together undervimrc/(alongside the IntelliJ IdeaVim one) and still let Neovim find it at the path it expects.
1. .gitconfig — switching identity in one alias
I have two git identities: a personal one and a work one. Forgetting to switch is how you end up committing personal-email commits inside a company repo. So:
[alias]
ci-personal = "!f() { git config user.name 'my-personal-handle'; \
git config user.email 'personal@example.com'; }; f"
ci-office = "!f() { git config user.name 'my-work-handle'; \
git config user.email 'work@example.com'; }; f"
whoami = git whoami config user.name && git config user.email
[init]
defaultBranch = main
First thing I do inside a freshly cloned repo: git ci-personal or git ci-office. Then git whoami to confirm. It writes to that repo’s local .git/config, not the global one — so each repo remembers who I am.
defaultBranch = main just stops git init from creating a master branch I’ll have to rename ten seconds later.
2. .tmux.conf — the few things that make tmux feel like home
The full file is ~50 lines. The decisions that actually matter:
unbind C-b
set-option -g prefix C-a
bind-key C-a send-prefix
I moved the prefix from C-b to C-a because C-b collides with “page up” in less/vim/man-pages. C-a only collides with “go to start of line” in shell — and I rarely need that inside a tmux pane.
set -g base-index 1
set -g pane-base-index 1
Windows and panes count from 1 instead of 0. The prefix-then-number motion (C-a 1, C-a 2) maps directly to the number row of the keyboard, where 1 is the first key. Starting at 0 means the first window is on the second key, which my hand never learns.
bind '"' split-window -v -c "#{pane_current_path}"
bind % split-window -h -c "#{pane_current_path}"
bind c new-window -c "#{pane_current_path}"
By default, tmux splits start in your home directory. That’s almost never what I want — I’m splitting because I want to run a second command here. #{pane_current_path} makes the new pane inherit the current one’s directory.
setw -g mode-keys vi
bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "pbcopy"
bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"
This is the one I’d miss most if it disappeared. Press prefix + [ to enter copy mode, navigate with hjkl, v to start selection, y to copy — and the selection lands in the macOS system clipboard via pbcopy. No Cmd-C, no losing my mouse position, no terminal weirdness with multi-pane copy.
bind h select-pane -L
bind j select-pane -D
bind k select-pane -U
bind l select-pane -R
bind -r H resize-pane -L 5
bind -r J resize-pane -D 5
bind -r K resize-pane -U 5
bind -r L resize-pane -R 5
Pane navigation in vim direction (lowercase), pane resizing in shifted vim direction (uppercase). The -r on the resize bindings means “repeatable” — I can press prefix once, then mash H four times in a row, instead of pressing the prefix between each.
3. zshrc — the boring half: shell + language toolchain
The file is split into roughly four parts: PATHs and language managers, oh-my-zsh setup, custom functions, and aliases. Here are the parts that actually do work.
Java toggling. I use both Java 17 and 21 depending on which project I’m in. The simplest version of “version manager” I could justify:
# Java 17
export JAVA_HOME="/usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home"
export PATH="$JAVA_HOME/bin:$PATH"
# Java 21
# export JAVA_HOME="/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"
# export PATH="$JAVA_HOME/bin:$PATH"
Comment one out, uncomment the other, source the file. I tried jenv for a while and got tired of fighting it. For a two-version setup, two lines and a comment beat a tool.
Other runtimes. NVM for Node, pyenv for Python, rbenv for Ruby. The pattern is the same every time:
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
The eval is the magic step — it injects a shim into the shell so that calling python actually goes through the version-manager’s lookup, not straight to /usr/bin/python.
Oh My Zsh. Theme robbyrussell (the default, the one with the arrow prompt that turns red on a failed command), git plugin for completions, vi keybindings via bindkey -v. That’s it. I keep being tempted by powerlevel10k and keep deciding I don’t actually need it.
Git aliases. These get used hundreds of times a day:
alias ga='git add .'
alias gr='git restore --staged .'
alias gc='git commit -m'
alias gp='git push'
alias gl='git pull'
alias gs='git status'
alias glog='git log --oneline --graph --all --decorate'
alias gco='git checkout'
alias gcb='git checkout -b'
alias glc='git log -1 --stat'
The one I want to call out is glog — --oneline --graph --all --decorate is the four-flag combo that turns git log into the branch-diagram view that every git GUI tries to replicate. Once you’ve typed glog once, you stop needing a GUI to “see the shape” of the repo.
4. zshrc — the interesting half: deployment + tmux session helpers
The custom functions are where the file earns its keep.
One-key deployment to a remote Tomcat. I deploy WARs to three different servers. Each used to be: cd into the project, run mvn clean package, then scp the WAR to the right path. Four commands, easy to do in the wrong order. Now:
upload_to_prod_a() {
PROJECT_DIR="$HOME/Documents/Github/main-api"
WAR_PATH="$PROJECT_DIR/target/main-api.war"
echo "Building the project..."
cd "$PROJECT_DIR" || { echo "Project directory not found"; return 1; }
mvn clean package || { echo "Build failed"; return 1; }
echo "Deploying WAR to remote server..."
scp "$WAR_PATH" deploy@prod-a:/opt/tomcat/webapps/ || \
{ echo "Deployment failed"; return 1; }
echo "Deployment completed successfully!"
}
Two important things going on here:
- Each step
|| { … return 1; }— if the build fails, I never get to thescp. That’s the whole reason this is a function and not three aliases. - The
scptarget isprod-a, not an IP. That short name resolves because of~/.ssh/config, which is the other file that makes this whole setup work. (I’ll do a.ssh/configpost separately — it’s its own thing.)
There’s also a _with_backup variant, which does the same dance but first SSHes into the box and copies the current WAR into a timestamped webservices_backup/ folder. That came out of one production rollback I never want to repeat.
tmux-dev — boot a whole workday. This is my favourite function in the entire file. Running tmux-dev does this:
Window 0: nvim (in ~/projects)
Window 1: workspace (empty shell in $HOME)
Window 2: alibaba-icm (split: tail catalina.out / shell on the server)
Window 3: hetzner (split: tail catalina.out / shell on the server)
Window 4: eosium-cpp (cd'd into my C++ side project)
Window 5: P-8080 (mvn spring-boot:run -Plocal in the local API)
It checks tmux has-session -t dev-hub first, so running it a second time just attaches to the existing session instead of rebuilding it. Cold start in the morning: open WezTerm, type tmux-dev, the whole workspace materialises — backend running, two server log tails ready, editor in the right folder. Closing the laptop lid doesn’t kill any of it.
There are also tmux-hetzner and tmux-alibaba-icm for when I only need one of those server windows on its own.
5. The runaway PATH — a small horror story
Near the top of my zshrc I have, inexplicably:
echo 'export PATH="/Applications/Postgres.app/Contents/Versions/latest/bin:$PATH"' >> ~/.zshrc
That line does not export the PATH. It appends another export line to the file itself. Every time I open a terminal, the file gets one line longer. I wrote it months ago when I was setting up Postgres.app and somehow combined “add this line to your shell config” with “I’ll do it from inside the shell config.” Predictable result: the file is now ~990 lines long and ~970 of those lines are the same export PATH=…postgres… line stacked on top of each other.
The shell doesn’t actually care — re-exporting the same PATH is a no-op — so nothing visibly broke. I only noticed when I went to add a new alias and had to scroll past the wall of duplicates to find the right spot.
The fix is a one-liner (export PATH="/Applications/Postgres.app/Contents/Versions/latest/bin:$PATH" — no echo, no >> ~/.zshrc), and then delete the duplicates. I’ve left the file as-is in this post on purpose, because the lesson is the interesting part: any >> ~/.zshrc inside ~/.zshrc is a footgun. Shell init files should be the configuration, not modify the configuration.
6. Two terminals: Alacritty for “just run it”, WezTerm for everything else
I have configs for both, and I use both for different reasons.
Alacritty (alacritty.toml) — five lines, the whole file:
[font]
normal = { family = "JetBrainsMono Nerd Font", style = "Regular" }
size = 15.0
[window]
startup_mode = "Fullscreen"
Font, size, fullscreen on launch. That’s it. Alacritty is what I use when I want a terminal now and I don’t care about tabs, splits, or backdrops. It opens instantly, renders fast, and doesn’t have opinions.
WezTerm (wezterm.lua) — multi-file Lua config split into modules:
local Config = require('config')
require('utils.backdrops')
:set_focus('#000000')
:set_images_dir(wezterm.home_dir .. '/.dotfiles/.config/wezterm/backdrops/')
:set_images()
:random()
require('events.left-status').setup()
require('events.right-status').setup({ date_format = '%a %H:%M:%S' })
require('events.tab-title').setup({ hide_active_tab_unseen = false })
return Config:init()
:append(require('config.appearance'))
:append(require('config.bindings'))
:append(require('config.domains'))
:append(require('config.fonts'))
:append(require('config.general'))
:append(require('config.launch')).options
WezTerm is what I use when I’m living in the terminal — the tmux-dev workday, long-running log tails, working on something for hours. It has tab titles, a clock in the corner, a random backdrop image, and SSH domains so I can open a new tab that’s already SSH’d into a remote host. The Lua config is the heavyweight version; Alacritty’s toml is the featherweight. Same machine, different jobs.
7. karabiner/ — Caps Lock becomes a fifth modifier
macOS has four modifiers: ⌃ ⌥ ⇧ ⌘. Almost every useful combination is already taken by some app. So I added a fifth: pressing Caps Lock sends ⌃⌥⇧⌘ simultaneously (the “Hyper” key). Nothing on macOS uses all four modifiers together, which makes it a clean shortcut namespace I own.
{
description: "Caps Lock -> Hyper Key",
from: { key_code: "caps_lock", modifiers: { optional: ["any"] } },
to: [{ set_variable: { name: "hyper", value: 1 } }],
to_after_key_up: [{ set_variable: { name: "hyper", value: 0 } }],
to_if_alone: [{ key_code: "escape" }],
type: "basic",
}
The to_if_alone: escape is the clever bit. If I press Caps Lock and release it without pressing anything else, it acts as Escape. Useful in vim. If I hold it while pressing another key, it’s Hyper. One key, two jobs, no conflict.
With Hyper set up, I have sub-layers:
Hyper + o + i→ open IntelliJHyper + o + s→ open SlackHyper + o + t→ open TerminalHyper + w + l→ snap current window to right-halfHyper + w + f→ maximize windowHyper + s + u/s + j→ volume up / down
The config is a TypeScript file (rules.ts) that compiles down to the JSON Karabiner-Elements actually reads. The TS side is much easier to maintain — I get types, autocomplete, and helpers like app("Slack") instead of hand-writing JSON. The original rules layout is adapted from a public config (Max Stoiber’s, originally) with the app list rewritten for what I actually use.
8. Neovim — minimal init.vim, with one tmux trick
~/.config/nvim/init.vim is symlinked to ~/.dotfiles/vimrc/nvim/init.vim. The full config is ~160 lines. Highlights:
set number
set relativenumber
set clipboard=unnamedplus
set scrolloff=4
set tabstop=2
set shiftwidth=2
set softtabstop=2
set expandtab
set wrap
set linebreak
set breakindent
set showbreak=↳\
Relative numbers for fast 5j/10k jumps, system clipboard integration, 2-space indent (I do a lot of YAML and JS), and soft-wrap with a visible continuation marker (↳) so I can see when a “line” is actually one logical line that’s wrapping.
nnoremap j gj
nnoremap k gk
When a line is soft-wrapped, plain j jumps over the wrapped portion to the next real line. gj moves down by visual row, which is what I actually want 99% of the time.
Plugins, via vim-plug:
Plug 'nanotech/jellybeans.vim' " colorscheme
Plug 'windwp/nvim-autopairs' " auto-close brackets
Plug 'neoclide/coc.nvim', {'branch': 'release'} " LSP/intellisense
Plug 'vim-airline/vim-airline' " statusline
Plug 'akinsho/bufferline.nvim' " tabs for buffers
Plug 'junegunn/fzf' " fuzzy file finder
Plug 'preservim/nerdtree' " file tree sidebar
Plug 'mfussenegger/nvim-dap' " debug adapter protocol
Plug 'rcarriga/nvim-dap-ui' " debugger UI
Coc.nvim is the heavyweight here — it gives me LSP-style completion, jump-to-definition, format-on-save for C/C++:
autocmd BufWritePre *.c,*.cpp,*.h,*.hpp silent! call CocAction('format')
The F5-runs-this-file trick. This is the part I’m proudest of, and it’s barely any code:
augroup RunFile
autocmd!
autocmd FileType python nnoremap <buffer> <F5> :w<CR>:silent
\ ![ $(tmux list-panes \| wc -l) -eq 1 ] && tmux split-window -h -p 40<CR>
\ :silent !tmux send-keys -t right "clear; python3 %" C-m<CR>
\ :silent !tmux select-pane -t left<CR>:redraw!<CR>
autocmd FileType cpp nnoremap <buffer> <F5> :w<CR>:silent
\ ![ $(tmux list-panes \| wc -l) -eq 1 ] && tmux split-window -h -p 40<CR>
\ :silent !tmux send-keys -t right "clear; cmake -S . -B build && cmake --build build && ./build/eosium" C-m<CR>
\ :silent !tmux select-pane -t left<CR>:redraw!<CR>
augroup END
What this does, read top-to-bottom:
- Save the file (
:w). - If there’s only one tmux pane in the current window, split off a right-hand pane that takes 40% of the width.
- Send a command into the right pane:
clear; python3 %(where%is the current file). For C++, it’s a full CMake build + run instead. - Move focus back to the left pane (where vim still is).
Net result: press F5 inside any Python or C++ file, and a side-pane spins up running that file. Edit, F5, see output, edit, F5 — all without leaving vim or touching the mouse. This only works because tmux is already the window manager for the terminal — vim doesn’t need its own terminal emulator, it just borrows tmux’s.
What’s not here, on purpose
- No fish, no powerlevel10k, no starship. I tried each. They’re nice. They also add a setup step on every new machine and a re-learning step every six months. zsh + oh-my-zsh +
robbyrusselldoes 90% of what I need, and I can sit down at a freshbashshell and still function. - No tmuxinator / smug. My
tmux-devfunction is ~40 lines of zsh and does exactly what I want. A YAML-config-driven tool would have to be learned, configured, kept up-to-date, and remembered to install on every machine. - No GUI git client.
gs,glog,gco, andgcbcover everything I do day-to-day. The one time I want a visual diff, I open IntelliJ.
The dotfiles answer the question “what is the smallest amount of customisation that turns a fresh Mac into a workstation I can actually work on?” Everything in ~/.dotfiles exists because, at some point, I noticed I was about to do something annoying for the second time.
"Tutto passa"