File Attributes¶
Attributes are #-suffixes on a filename that control how a file is deployed.
They are stripped from the target name, so config#encrypted#perm:600 deploys as
config.
.ssh/config#encrypted#perm:600 → ~/.ssh/config, decrypted, mode 0600
Order does not matter, and any number can be combined. An unrecognised attribute
is ignored silently but still stripped, so a typo such as config#templte
deploys an unrendered config. Everything after the first # in a name is
attributes, so a file whose real name contains # cannot be managed.
| Attribute | Effect |
|---|---|
#template |
Render as a Go template |
#tmpl |
Alias for #template |
#encrypted |
Decrypt with age before deploying |
#symlink |
Recreate a symlink (source must be one) |
#import |
Target is authoritative; changes flow back to the source |
#profile:name |
Only deploy under this profile |
#perm:600 |
Set file mode |
#owner:user |
Set owner |
#group:group |
Set group |
#perm-r:600 |
Mode for a directory and its children |
#owner-r:user |
Owner for a directory and its children |
#group-r:group |
Group for a directory and its children |
Attributes are written on directories too. There, only some take effect:
| On a directory | Effect |
|---|---|
#profile:name |
The directory and everything beneath it deploy only under that profile |
#perm:, #owner:, #group: |
The directory itself |
#perm-r:, #owner-r:, #group-r: |
The directory and everything beneath it |
| anything else | Nothing, but it is still stripped from the name |
The source directory's own name counts as well: a source named
etc#owner-r:root makes every file in it root-owned.
#template¶
Renders the file with Go's text/template before writing it. See
Templates for the available variables and functions.
gitconfig#template
[user]
email = {{ .Vars.email }}
#tmpl is an equivalent short form.
Because the deployed content differs from the source, statemate compares the rendered output against the target. A template whose variables change is detected as modified even though the source file itself did not change.
#encrypted¶
Marks the file as age-encrypted in the repository. It is decrypted on apply, so the target holds plaintext while the repository holds ciphertext.
.ssh/id_ed25519#encrypted#perm:600
Deploying needs an age identity (identity or identity_command in
age:) to decrypt with. Without one, mate apply stops
with an error rather than deploy ciphertext. Encrypting needs only the
recipients. See Encryption for setting up a key.
Use mate encrypt and mate decrypt to convert a file in place. They add and
remove the suffix for you.
mate edit decrypts to a temporary file, opens your editor, and re-encrypts on
save, preserving the original mode. mate cat decrypts to stdout.
Combines with #template: the file is decrypted first, then rendered.
#symlink¶
Reproduces a symlink at the target. The source file must itself be a symlink; statemate reads where it points and recreates a link to that same destination.
cd ~/dotfiles/mysource
ln -s /opt/homebrew/bin/nvim 'bin/vim#symlink'
~/dotfiles/mysource/bin/vim#symlink → /opt/homebrew/bin/nvim
⇓ apply
~/bin/vim → /opt/homebrew/bin/nvim
Use it for links to paths outside your repository, such as a binary in /opt or a
large directory you do not want to copy. Note that this does not link the target back
at the source file, so editing the deployed file does not edit the repository.
The link text is copied verbatim, so a relative link resolves relative to the target's location, not the source's. Change detection compares the link text: retargeting the source link is a change, and a link that points nowhere on this machine is deployed as-is.
A #symlink attribute on a regular file is an error, reported by mate status
and mate apply alike:
Error: computing changes: readlink .../f.txt#symlink: invalid argument
Because the link destination is copied verbatim, #template, #encrypted,
#perm:, #owner: and #group: have no effect alongside #symlink: there is
no content to render, decrypt, chmod, or chown. Links are created without sudo,
so a #symlink cannot target a directory you cannot write to.
If a target is a symlink but the source is not marked #symlink, statemate treats
it as a conflict the first time it sees it, rather than silently replacing it.
This is what happens on the first apply after migrating from GNU Stow; answer
[o]verwrite to replace the links with real files.
#import¶
Marks a file whose target is normally authoritative, because the application
owns it and rewrites it. ~/.claude/settings.json is the motivating case: it
changes whenever you toggle a setting, so without #import every mate apply
stops to ask about a drifted target, and the answer is always "import".
claude/.claude/settings.json#encrypted#import
| Source | Target | Result |
|---|---|---|
| unchanged | unchanged | nothing to do |
| changed | unchanged | source deployed to target, as usual |
| unchanged | changed | target imported into the source, no prompt |
| changed | changed | conflict prompt |
Divergence on both sides still prompts, deliberately: letting the target win there would discard an edit you made on purpose.
A missing target is created from the source, so #import works when setting up a
new machine — you get the file, and from then on the application's edits flow
back. On the first encounter with a pre-existing untracked target, statemate
still prompts once, because no recorded state exists to say which side is newer.
mate status marks a pending import with < (<N in --short), and mate diff
shows the diff in the import direction — target as the new side. mate apply
prints ← path (imported).
Composes with #encrypted: imported content is encrypted before being written to
the source, so the repository never holds plaintext.
Cannot be combined with #template. Importing would write the rendered output
over the template, destroying the source. Statemate rejects the combination with
an error rather than doing it.
#profile:name¶
Deploys the file only when the named profile is active. Profile inheritance is
respected, so a file marked #profile:base also applies under a profile that
extends: base.
.gitconfig#profile:work
Under any other profile the file is skipped entirely: it is neither deployed nor reported as a change.
When no profile is active, there is nothing to filter by and every
#profile:file is deployed. Two variants of the same target then conflict. Define a profile for every machine; see Different Machines.
Several files may therefore claim the same target, one per profile:
.claude/settings.json#profile:personal
.claude/settings.json#profile:work
Only variants that deploy together count as a
conflict — which happens when inheritance brings
two of them into the same profile chain, such as a #profile:base variant next to
a #profile:work one under a profile that extends: base.
#perm:600¶
Sets the file mode, in octal.
.ssh/config#perm:600
Without this attribute the source file's own mode is used when the file is written, and the target's mode is not checked afterwards. With it, a target whose mode differs is reported as modified and corrected on apply.
A value that is not valid octal, such as #perm:rw, is ignored.
#owner:user¶
Sets the file owner. statemate uses sudo for targets in locations you cannot
write to, such as /etc. In a location you can write to, the chown runs as you
and fails unless you are root, so #owner: belongs on system files.
Ownership is applied when the file is written. Unlike #perm:, a target whose
owner differs is not reported as modified, so adding #owner: to a file that is
already deployed takes effect the next time its content changes.
etc/nginx/nginx.conf#owner:root
Usually more convenient as a source-wide default in
.mate.yaml, or as #owner-r: on a
parent directory.
#group:group¶
Sets the file group, with the same elevation caveat as #owner:.
etc/wireguard/wg0.conf#group:systemd-network
Recursive attributes¶
#perm-r:, #owner-r: and #group-r: on a directory apply to that directory
and everything inside it. Children inherit the value as a default, so an
explicit attribute on a child still wins, and the nearest ancestor's value wins
over one further up.
#perm-r: gives files and subdirectories the same mode. That suits 755
(a bin directory) and 700, but #perm-r:600 would leave subdirectories
untraversable. For a private directory of private files, combine #perm:700 on
the directory with #perm:600 on the files.
The order of precedence for a file's mode, owner, and group:
- Its own attribute
- The nearest ancestor's
-rattribute - The
perm,ownerandgroupdefaults in the source's.mate.yaml
etc#owner-r:root#group-r:root/
ssh/
ssh_config.d/
50-storagebox.conf ← owned by root:root, inherited
99-local.conf#owner:me ← explicitly overridden
This is how a whole system directory is managed without annotating every file. A
real example, deploying to /etc as root:
restic/
.mate.yaml # targets: { etc: /etc }
etc#owner-r:root/
ssh/ssh_config.d/50-storagebox.conf#template
Note that directories which already exist and carry no perm/owner/group attribute
are left completely untouched, so mapping a root like /etc never chmods it.
Empty directories¶
Every directory in a source is created on the target, whether or not it contains files. That is how a directory which only needs to exist is declared — a socket directory, a cache directory some program refuses to create itself:
ssh/
.cache/ssh/controlmasters#perm:700/
.gitkeep ← so git stores the directory at all
.ssh/config#template
Git cannot store an empty directory, so the keep-file is unavoidable. Hide it
from the target with an ignore
pattern in the source's .mate.yaml; ignoring a file does not remove its parent
directory from the tree:
ignore:
- .gitkeep
mate status lists a declared empty directory as + until it exists. Directories
that contain files are not listed separately — they arrive with those files.
Deleting the directory from the source stops it from being created, but does not
remove it from the target: unlike files, directories are not tracked in the state
database, so mate status will not report it as orphaned.