Skip to content

Tips & Tricks

Integrations that make statemate disappear into your daily workflow. Most of these come from a real dotfiles repository managed with statemate; follow the links for the complete files.

Edit the source, not the target

Every deployed file is overwritten by the next mate apply, so an edit made to ~/.zshrc directly is either lost or turns into a conflict. mate edit takes the target path and opens the source for you:

mate edit ~/.zshrc                 # opens zsh/.zshrc in the repository
mate edit ~/.ssh/config            # decrypts, opens, re-encrypts on save

If you edited the target anyway, mate apply stops at the conflict and offers [i]mport, which copies your edit back into the source.

Shell

Completion

mate completion prints a completion script for bash, zsh, fish, or PowerShell:

# ~/.zshrc
source <(mate completion zsh)

# ~/.bashrc
source <(mate completion bash)

# fish
mate completion fish | source

Completion is context-aware: mate apply, diff and status complete managed files, mate clean completes orphans, mate scripts run and mate hooks run complete script and hook names, and --source / --profile complete from your configuration.

Aliases

Short aliases for the commands you run most:

alias ds='mate status'
alias da='mate apply'
alias dad='mate apply --dry-run --verbose'
alias de='mate edit'
alias ddiff='mate diff'
alias ddoc='mate doctor'

And a quick way into the repository from anywhere:

alias cdm='cd "$(mate config source-dir)"'

Reload every open shell after a change

A hook can signal running shells when their configuration changes, so open terminals pick up a new alias without being restarted:

# mate.yaml
hooks:
  shell-reload:
    match:
      - ".config/zsh/**"
      - ".zshenv"
    do:
      - run: pkill -USR2 -u "$USER" zsh || true
# .zshrc — re-source the configuration when SIGUSR2 arrives
TRAPUSR2() { source "${ZDOTDIR:-$HOME}/.zshrc" }

The || true keeps the hook from failing when no zsh is running.

Neovim

Three problems come up when editing a statemate repository in Neovim, and an autocommand plugin solves each of them. The complete version is plugin/statemate.lua.

Filetype detection for source files

Neovim reads settings.json#profile:work#encrypted as a file with the extension json#profile:work#encrypted, so highlighting and LSP stop working. Strip the attributes and detect again:

vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, {
  pattern = "*#*",
  callback = function(args)
    local name = vim.fs.basename(args.file):gsub("#.*$", "")
    local ft = vim.filetype.match({ filename = name, buf = args.buf })
    if ft then
      vim.bo[args.buf].filetype = ft
    end
  end,
})

Only the basename is stripped, so attributes on a parent directory (etc#owner-r:root/) are left alone.

Offer the source when a deployed file is opened

Opening ~/.ssh/config is almost always a mistake when the file is managed. mate managed <path> resolves a target to its source, so a BufReadPost autocommand can ask which one you meant:

statemate: managed file, edit which?
1: source: config#encrypted
2: target: ~/.ssh/config

The plugin runs mate managed on the opened path and switches the buffer to the source if you pick it. It skips files whose ACTIVE column is empty, since their source belongs to a profile that is not in use.

Edit #encrypted files in place

BufReadCmd and BufWriteCmd autocommands on *#encrypted* can decrypt with age --decrypt --identity <key> on read and re-encrypt with age --armor --recipient <key>… on write, reading the recipients from mate.yaml. Disable the swap file, undo file, and backups for those buffers so the plaintext never reaches the disk.

Use --armor: statemate writes armored ciphertext, and matching it keeps git diff readable whichever tool last wrote the file.

Git

Readable diffs for encrypted files

Encrypted sources are ciphertext, so git diff and git log -p show nothing useful. A textconv filter decrypts them for display only; the files stay encrypted on disk and in the repository:

# .gitattributes in the repository
*#encrypted* diff=age
# ~/.gitconfig
[diff "age"]
    textconv = age --decrypt --identity ~/.config/statemate/key.txt

The pattern ends in * because #encrypted need not be the last attribute (settings.json#encrypted#import). Tools that use git's diff machinery, such as lazygit and tig, pick this up too.

Diffs with an external tool

diff_tool in mate.yaml replaces the built-in diff -u:

diff_tool: difft

statemate runs the tool as <tool> <old> <new> through sh, so the setting may include options (diff_tool: delta --side-by-side). The output is captured rather than shown on the terminal, so the tool cannot detect the terminal's width. A wrapper that reads the width from the terminal itself:

#!/bin/sh
# ~/.local/bin/mate-diff
cols=$( (stty size < /dev/tty) 2>/dev/null | cut -d' ' -f2 )
exec delta --side-by-side --width="${cols:-160}" "$1" "$2"
diff_tool: mate-diff

mate diff --tool <name> overrides the setting for one run.

Status in your status bar

mate status --short prints one compact line meant for status bars, and nothing at all when everything is up to date:

~2 !1 *1
Token Meaning
+N new files
~N modified files
!N conflicts
<N pending imports
?N orphans
*N pending scripts
sN secrets needing a fetch

It exits non-zero when it cannot run at all, such as when the configuration does not load, so a status bar can tell "clean" from "broken". Every run scans your sources, so poll it every few seconds rather than continuously.

tmux

A status-line segment, colouring each token:

#!/usr/bin/env bash
# ~/.local/bin/tmux-statemate
status=$(mate status --short 2>/dev/null) || exit 0
[[ -z "$status" ]] && exit 0
for token in $status; do
  case "$token" in
    !*) printf '#[fg=red]%s ' "$token" ;;
    ~*) printf '#[fg=blue]%s ' "$token" ;;
    *)  printf '#[fg=default]%s ' "$token" ;;
  esac
done
set -g status-interval 10
set -ag status-right '#(tmux-statemate)'

And a key binding that runs mate apply in a popup, after confirming:

bind . confirm-before -p "Run mate apply?" \
  "display-popup -E 'mate apply --verbose; read -n 1 -s -r -p \"Press any key\"'"

The full versions are __statemate_status and 50-statemate.conf.

Waybar

The same script can emit JSON for a Waybar custom module, with the full mate status as the tooltip:

"custom/statemate": {
  "exec": "__statemate_status waybar",
  "return-type": "json",
  "interval": 10,
  "on-click": "mate apply"
}
# the waybar branch of the script
if [[ -z "$status" ]]; then
  jq -cn '{text: "", class: "clean", tooltip: "statemate: up to date"}'
else
  jq -cn --arg t "$status" --arg tip "$(mate status 2>/dev/null)" \
    '{text: $t, class: "dirty", tooltip: $tip}'
fi

One palette, every tool

Variables can be nested, and an include file can hold nothing but variables. Together they make a single theme file that feeds every application's colours:

# .mate/theme.yaml
variables:
  color:
    bg:
      default: 1a1e24
      dark: 15181e
    fg:
      default: d7d7d7
# mate.yaml
include:
  - .mate/theme.yaml
# kitty/.config/kitty/theme.conf#template
background #{{ .Vars.color.bg.default }}
foreground #{{ .Vars.color.fg.default }}

Changing a colour in one place redeploys every template that uses it on the next mate apply. Renaming or removing a key breaks every template that refers to it. Run grep -r 'color.bg.dark' first, and mate eval the templates you touched.