Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0f4c011
docs(changelog): explain the Homebrew trust step for the cask migration
subbeh Oct 16, 2026
ff8b89e
feat(source)!: shorten attributes to #enc, #tmpl and #link
subbeh Oct 16, 2026
887fa00
chore(release): merge main back into develop after v0.6.0
subbeh Oct 16, 2026
7aeaa32
docs(claude): merge main back into develop after each release
subbeh Oct 16, 2026
0b92a8f
feat(add): offer to create a missing source directory
subbeh Oct 16, 2026
40d56e7
feat(config)!: rename the local config to config.yaml
subbeh Oct 16, 2026
d0b14f2
feat(config)!: replace variable_commands with templated variables
subbeh Oct 16, 2026
3bed86f
feat(unmanaged): list files in a directory that no source deploys
subbeh Oct 16, 2026
e2f7185
feat(unmanaged): list only files, not directories
subbeh Oct 16, 2026
107a2f3
feat(cli): color mate's output
subbeh Oct 16, 2026
ea3cffc
feat(cli): add --no-color flag
subbeh Oct 16, 2026
4379e12
feat(cli): add --log-file and --log-level for debugging
subbeh Oct 16, 2026
24a8c37
docs: add comparison with chezmoi, yadm, stow, and dotbot
subbeh Oct 16, 2026
63c9586
feat(scripts): add #noconfirm attribute and hook confirm field
subbeh Oct 16, 2026
02de9f6
feat(config): allow a targets mapping to list several paths
subbeh Oct 20, 2026
576db15
feat(logging): add -v/-vv stderr logging and an audit log at info
subbeh Oct 23, 2026
0a2f592
ci(release): read Go version from go.mod
subbeh Oct 23, 2026
403757f
chore(hooks): rebuild dist after commit, merge and checkout
subbeh Oct 23, 2026
664e473
refactor(plan): resolve config, profile and changes through one package
subbeh Oct 23, 2026
6900ebb
refactor(apply): build the apply run from the plan
subbeh Oct 23, 2026
f509e24
fix(target): read sudo-only targets with BSD stat on macOS
subbeh Oct 23, 2026
ca37dae
refactor(source): record each entry's source directory
subbeh Oct 24, 2026
66f4260
feat(plan)!: resolve file arguments as paths in one place
subbeh Oct 24, 2026
7d2b648
refactor(cli): pass the plan Env instead of rebuilding it
subbeh Oct 24, 2026
f372b43
refactor(target): render each entry to its target bytes in one place
subbeh Oct 24, 2026
5305798
chore(skills): update run-statemate for the #tmpl rename and [Y]es pr…
subbeh Oct 24, 2026
aa3ed89
test(target): diff a target only sudo can read
subbeh Oct 24, 2026
8517107
feat(hooks): drop the STEPS column from mate hooks list
subbeh Oct 24, 2026
e455a93
docs(readme): mention maintenance tasks and align attribute examples
subbeh Oct 24, 2026
52728ba
feat(packages)!: rename mate packages status to list
subbeh Oct 24, 2026
faf541f
fix(packages): sort each manager's packages by name
subbeh Oct 24, 2026
6a9e2d0
feat(packages)!: declare packages as a list, one per item
subbeh Oct 24, 2026
0aeebe5
ci: run golangci-lint v2.15, built with a Go new enough for go.mod
subbeh Oct 24, 2026
93eb5f3
feat(scripts): add # Onchange: comment to name what triggers an #onch…
subbeh Oct 25, 2026
06c5971
chore(release): prepare v0.7.0
subbeh Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .claude/skills/run-statemate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export STATEMATE_SCRATCH=$(mktemp -d)/sm
python3 .claude/skills/run-statemate/driver.py scaffold
```

The scaffold covers a plain file, a `#template` file, a `#perm-r:755` directory,
The scaffold covers a plain file, a `#tmpl` file, a `#perm-r:755` directory,
a per-source `.mate.yaml` with packages, and two scripts (`#once#before` with a
`# Description:`, `#always#after`).

Expand Down Expand Up @@ -110,9 +110,9 @@ Verified prompt output — note `once` offers `[s]kip` and `always` does not:
```
Run setup.sh (before, once)?
Create a marker proving the script ran
[y]es / [n]o / [s]kip (mark as done) / [a]ll / [q]uit: n
[Y]es / [n]o / [s]kip (mark as done) / [a]ll / [q]uit: n
Run after.sh (after, always)?
[y]es / [n]o / [a]ll / [q]uit:
[Y]es / [n]o / [a]ll / [q]uit:
```

## Direct invocation (for PRs touching internals)
Expand Down Expand Up @@ -164,16 +164,16 @@ Use the driver.
- **A source's packages ignore its `profile:` key.** `internal/packages/sync.go`
collects `dirCfg.Packages` unconditionally; `profile:` in `.mate.yaml` gates
*files* only. You cannot hide a package behind a non-matching profile.
- **`mate` resolves the repo from config, not cwd.** `~/.config/statemate/mate.yaml`
- **`mate` resolves the repo from config, not cwd.** `~/.config/statemate/config.yaml`
sets `source_dir`, which beats the current directory. `cd`-ing into a scratch
repo is **not** enough — you must set `STATEMATE_DIR` (the driver does).
- **Resolve symlinks in scratch paths.** On macOS `mktemp -d` returns
`/var/...` which is a symlink to `/private/var/...`. `mate edit` and
`mate managed <path>` compare resolved paths, so an unresolved path yields
`... is not managed by mate`. The driver calls `.resolve()`.
- **`#` in filenames.** Safe as a mid-word shell argument in bash and zsh
(`file.conf#template` needs no quoting). But it *is* nvim's alternate-file
token, so `:edit path#encrypted` fails with `E194` — use `:edit path\#encrypted`
(`file.conf#tmpl` needs no quoting). But it *is* nvim's alternate-file
token, so `:edit path#enc` fails with `E194` — use `:edit path\#enc`
or `vim.fn.fnameescape`.
- **`make build-all` skips linux/arm64.** Only darwin-arm64 and linux-amd64.

Expand Down
6 changes: 3 additions & 3 deletions .claude/skills/run-statemate/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def scaffold() -> Path:
# A plain source, a templated file, and a per-source .mate.yaml.
(repo / "app" / ".config" / "app").mkdir(parents=True)
(repo / "app" / ".config" / "app" / "settings.conf").write_text("mode=plain\n")
(repo / "app" / ".config" / "app" / "rendered.conf#template").write_text(
(repo / "app" / ".config" / "app" / "rendered.conf#tmpl").write_text(
"greeting={{ .Vars.greeting }}\n"
)
# A deliberately non-existent package, so `status` always has something to
Expand All @@ -120,7 +120,7 @@ def scaffold() -> Path:
# "missing", and `mate apply --force` would try to install it and abort the
# apply when that fails. Never pass bare --force here; see apply below.
(repo / "app" / ".mate.yaml").write_text(
"packages:\n common:\n - statemate-no-such-package\n"
"packages:\n - statemate-no-such-package\n"
)

# A source whose files need executable permissions via a recursive attr.
Expand Down Expand Up @@ -307,7 +307,7 @@ def smoke() -> None:
# --- interactive apply under a PTY --------------------------------------
# 'y' runs the before-script, 'y' again for the after-script.
code, out = run_pty(["apply"], keys="y\ny\n", root=root)
expect(out, "[y]es", "apply prompts for script confirmation")
expect(out, "[Y]es", "apply prompts for script confirmation")
if not (root / "ran-setup").exists():
die(f"answering 'y' should have run the script:\n{out}")
log("ok: PTY prompt ran the script")
Expand Down
1 change: 1 addition & 0 deletions .githooks/post-checkout
11 changes: 11 additions & 0 deletions .githooks/post-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
#
# Rebuilds dist/ after a commit, merge or checkout (post-merge and
# post-checkout link here), so a `mate` symlinked to dist/mate-<os>-<arch>
# always runs the checked-out code. Git ignores the exit status of these hooks,
# so a failed build only warns.

# post-checkout passes 0 as $3 for a file checkout, which moves no commit.
[ "${3:-1}" = 0 ] && exit 0

make -s build-all >/dev/null || echo "⚠ make build-all failed: dist/ is stale" >&2
1 change: 1 addition & 0 deletions .githooks/post-merge
3 changes: 2 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v7
with:
version: v2.6
# Must be built with at least go.mod's Go version, or it refuses to load.
version: v2.15

# docs/commands/ is generated from the cobra command tree and committed, so a
# help-text change must be regenerated in the same commit. Without this, the
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
go-version-file: go.mod

- name: Set up AUR SSH key
run: |
Expand Down
43 changes: 42 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.7.0] - 2026-10-25

### Added
- `mate add -s <name>` offers to create the source when its directory does not exist yet, then adds the file to it. The config is not changed: mate warns that the new source must still be listed under `sources:` before it is deployed
- `mate unmanaged [dir]` lists the files under the current directory, or the given one, that no source deploys for any profile. Only files are listed, not directories, and paths are relative to the current directory so they can be passed to `mate add`
- Colored output: change markers (`+` `~` `!` `<`), `mate doctor`'s `[OK]`/`[WARN]`/`[ERROR]`, warnings, errors, script and hook progress, and section headers. Color is off when stdout is not a terminal, `NO_COLOR` is set, or `--no-color` is passed
- `--log-file <path>` appends an audit log of what each run changed: target files written, moved or removed, source-tree changes (add, edit, rename, encrypt, decrypt, import, delete), forgotten files, secrets fetched, scripts and hooks run, packages installed or uninstalled, and the error a run ended with. A run that changed nothing, including any `--dry-run`, leaves no line. `--log-level debug` adds every external command mate runs with its exit code, the config and profile resolved, each file's status, conflict answers, and which scripts and hooks were due. Both can also be set with `STATEMATE_LOG_FILE`/`STATEMATE_LOG_LEVEL` or `log_file`/`log_level` in `mate.yaml` or the local config. Command output and environment are never logged
- `-v`/`--verbose` on every command prints the same log records to stderr as they happen: `-v` for what changed, `-vv` for the debug detail too. It does not need a log file and does not change the file's level. `mate -v` no longer prints the version; use `mate --version`
- `#noconfirm` script attribute and `confirm: false` hook field: the script or hook runs during `mate apply` without asking, even when there is no terminal. `mate scripts list`, `mate hooks list` and `mate status` mark them `noconfirm`. Adding `#noconfirm` to an existing script renames it, so a `#once` script runs again
- A `targets` mapping in a source's `.mate.yaml` can list several paths, deploying the subdirectory to each of them: `targets: {.claude.shared: [.claude.personal, .claude.work]}`
- An `#onchange` script can name what triggers it in a `# Onchange:` comment near its top: `self` runs it when the script itself has changed since its last run (or has never run), and target patterns such as `/etc/keyd/*.conf` run it when a matching file in its source has pending changes. With the comment, other changes in the source no longer trigger the script; without it, nothing changes. An empty comment, a pattern that does not parse, or the comment on a script that is not `#onchange` stops `mate apply` with an error naming the script

### Changed
- **Breaking:** `variable_commands` is removed. A variable's value may now be a template instead, so `variable_commands: {key: "gpg ..."}` becomes `variables: {key: '{{ cmd "gpg ..." }}'}`. Template values are rendered in `variables`, `include`, `var_files` and profile variables, including list items and nested keys, so a list such as `gpg_keys` can come from commands too. A leftover `variable_commands` key is ignored, leaving those variables undefined
- **Breaking:** `{{ cmd "..." }}` now fails rendering, showing the command's stderr, when the command exits non-zero. Before, it returned an empty string, so a broken command deployed a file with the value missing. Append `|| true` to the command to accept a failure
- **Breaking:** the local config is renamed from `~/.config/statemate/mate.yaml` to `~/.config/statemate/config.yaml` (`config.yml` works too). The old file is not read anymore: until you rename it with `mv ~/.config/statemate/mate.yaml ~/.config/statemate/config.yaml`, its `source_dir`, profile and other overrides are ignored and `mate` looks for `mate.yaml` in the current directory
- **Breaking:** the `#encrypted`, `#template` and `#symlink` attributes are renamed to `#enc`, `#tmpl` and `#link`, and the long forms are no longer recognised. Scripts use `#tmpl` too. Rename existing sources, for example `ssh/.ssh/config#encrypted` to `ssh/.ssh/config#enc`; mate stops with "#encrypted was renamed to #enc" until they are. `mate encrypt` and `mate add --encrypt`/`--template` write the short forms, and a `var_files` or `include` entry named without a suffix finds the `#enc` file. Recorded script runs follow the rename, so `#once` scripts do not run again
- **Breaking:** `-v` now means log output on every command, so the per-command verbose flags are renamed or removed: `mate apply -V`/`--verbose` is now `-l`/`--list`, `mate packages status -v`/`--verbose` is now `-d`/`--descriptions`, and `mate scripts run -v` and `mate hooks run -v` are removed; with `-v` they log each script and hook they run, and `-vv` shows each hook command
- An unrecognised attribute in a file, directory or script name is now an error that names the file, and so is a `#perm:` mode that is not octal. Before, it was dropped silently, so a typo such as `config#templte` deployed an unrendered `config`
- When the age identity fails to load (a missing key file, or an `identity_command` that fails), `mate status`, `diff`, `check` and `eval` now report why, naming the `#enc` file that needed it: `<file> is #enc, but the age identity failed to load: <reason>`. Before, status and check said no identity was configured, and diff and eval failed even when nothing had to be decrypted
- `mate apply`, `status`, `diff`, `check`, `scripts list` and shell completion run `age.identity_command` fewer times, so an identity that prompts (a password manager, a hardware key) asks less often
- **Breaking:** `mate rename`, `mate encrypt`, `mate decrypt`, `mate delete` and `mate edit` take their file as a path, the way `mate edit` already did: absolute, under `~`, or relative to the current directory, and a relative path that is not there is also looked up in the source directory. It names the file by its source, with or without the attributes (`nvim/.config/nvim/init.lua` finds `init.lua#tmpl`), or by its target. rename, encrypt and decrypt no longer search for a bare name or a path suffix: `mate encrypt secrets.yaml` must become `mate encrypt nvim/secrets.yaml`. That search took the first file that matched, so `mate rename config ...` could rename a different file than the one meant
- `mate delete` also accepts the source file, and finds files from every source, not only the current profile's. When a target has a variant per profile, the commands above act on the current profile's and otherwise stop, listing the variants
- `mate rename` and `mate encrypt` now build the full template context to render `.mate.yaml`, which loads the age identity: an `identity_command` runs once more than before
- `mate status`, `diff` and `check` stop with an error naming the file when a `#tmpl` file cannot be rendered, such as a template error or a secret that is not cached. Before, they compared the unrendered template with the target and reported a change that `mate apply` then failed on. `mate diff` no longer notes "(secrets not cached)" per file and carries on; it fails with the same message as the other commands
- `mate diff` labels each side with the target and the source instead of a temporary file, shows a file that is not deployed yet as a normal diff against `/dev/null`, and reports a binary file under its `===` header. A `diff_tool` gets temporary files named after the target, so it can tell the file type from the extension
- Answering `[d]iff` to a conflict during `mate apply` uses `diff_tool` when it is set

- `mate hooks list` drops the STEPS column, which only counted a hook's `do:` entries. `mate hooks run <name> --dry-run` shows the steps themselves
- **Breaking:** `mate packages status` is renamed to `mate packages list`, matching `mate scripts list` and `mate hooks list`. Its flags are unchanged
- `mate packages list` sorts each manager's packages by name, with `--all` extras after the declared ones. Before, they were grouped by the source that declared them, and `mate apply` and `mate packages apply` listed missing packages in that order too
- **Breaking:** `packages:` is a list with one package per item, replacing the `brew:`, `pacman:`, `aur:` and `common:` lists. A bare name (`- ripgrep`) goes to the system manager, brew or else pacman, as `common` did. A name followed by managers is installed with exactly those, each optionally under another name: `- gnu-sed: brew`, `- gh: [brew, pacman: github-cli]`, `- tpack: [brew, aur: tpack-bin]`. The old lists stop with an error that shows the new form, and so do an unknown manager, a manager listed twice, or `pacman` and `aur` on one item. In TOML it is an array, `packages = ["ripgrep", {gnu-sed = "brew"}]`, written before the file's first table
### Fixed
- `mate diff`, and `[d]iff` at the conflict prompt, work for targets only readable with sudo, such as a root-owned file under `/etc`. Before, they printed `Permission denied`
- `[d]iff` at the conflict prompt shows what apply would write for a file generated by `.mate.yaml`, and the link text for a `#link`. Before, it diffed the source directory, or what the link points at
- A relative `targets` path such as `.config` now deploys under the global target base (normally `~`). Before, it produced a relative target path
- `mate check` marks an `#import` change with `<`, as `mate status` does, instead of leaving the marker blank
- On macOS, targets that can only be read with sudo are compared again. mate ran GNU `stat` options that macOS's `stat` rejects, so `mate status`, `diff`, `check` and `apply` reported every such target as skipped, even with `--sudo`. A symlink in such a target's place is now also recognised as one, on Linux too
- `mate rename`, `mate encrypt`, `mate decrypt` and `mate edit` read a templated `.mate.yaml` rendered, as every other command does. Unrendered, a `.mate.yaml` with template syntax was skipped, so its `targets` and `target_base` were ignored and a file given by its target path was not found
- `mate scripts list` no longer shows a source's `#onchange` scripts as pending when its only change is a `#tmpl` file that is already up to date. It compared the unrendered template with the deployed file, so it disagreed with `mate status` and `mate apply`

## [0.6.0] - 2026-10-17

### Added
Expand All @@ -16,7 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- A source's `.mate.yaml` accepts `include`, like `mate.yaml`. Included packages count as that source's packages, and included variables are available to every template while the source is active, including the `.mate.yaml` itself. Paths are relative to the source directory. Previously the key was silently ignored

### Changed
- The Homebrew package is now a cask instead of a formula. `brew install subbeh/tap/statemate` works as before, and existing formula installs are moved to the cask on `brew upgrade`
- The Homebrew package is now a cask instead of a formula. `brew install subbeh/tap/statemate` works as before. `brew update` migrates an existing formula install, but Homebrew 7 only installs a trusted cask, so if it reports that the migration target is not trusted, run `brew uninstall --formula statemate`, `brew trust --cask subbeh/tap/statemate` and `brew install --cask subbeh/tap/statemate`
- `mate apply` now lists the files it is about to change and asks for confirmation before doing anything. Pass `--noconfirm` to skip the prompt (`--force` and `--dry-run` skip it too). Without a terminal the apply stops, so unattended runs must pass `--noconfirm` or `--force`
- `mate clean`, `mate delete` and `mate packages apply` accept `--noconfirm` as well, as an alias for their `--force` / `--yes` flag, so the same flag skips prompts on every command that asks
- Pressing Enter at a script or hook prompt (`[Y]es / [n]o / [a]ll / [q]uit`) now runs it, as `y` does. Before, Enter was ignored and the prompt waited for a letter
Expand Down
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ non-test files under `internal/` or `cmd/` without staging `CHANGELOG.md` are re
The same hook regenerates `docs/commands/` when `internal/cli/` changes, and rejects
the commit if the result is not staged.

`.githooks/post-commit` (linked as `post-merge` and `post-checkout`) runs
`make build-all` so `dist/`, which the installed `mate` symlinks to, tracks the
checked-out code. It only warns when the build fails.

One-time setup after cloning (`core.hooksPath` is local git config, not version-controlled):

```bash
Expand Down Expand Up @@ -105,6 +109,17 @@ Active development happens on the `develop` branch. Commit directly there — no
6. **Push**: `git push` directly to develop

When ready for a release, merge `develop` into `main` (via PR or direct merge).
Tag the merge commit on main (`git tag -a vX.Y.Z -m vX.Y.Z`) to run the release
workflow. Then merge `main` back into `develop` and push it:

```bash
git checkout develop && git merge --no-ff origin/main && git push
```

The tag sits on main's merge commit, so without this step develop never contains
it and `git describe` -- the version `make build-all` stamps into `mate version`
-- keeps reporting the previous release. The repo deletes the head branch when a
PR merges, so `develop` may have to be pushed back first anyway.

**No direct pushes to main** — main is the stable release branch.

Expand Down
16 changes: 16 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Statemate

Statemate deploys files from source directories in a repository to their targets
on a machine, as a profile selects them.

## Language

**Env**:
Everything one run of mate resolves before it looks at any source: the config,
the profile, the age identity and the template context.
_Avoid_: setup, context, environment (environment variables are a separate thing)

**Plan**:
What the sources say should be on disk for one Env, and how that differs from
the targets and the state database: entries, conflicts, changes and orphans.
_Avoid_: desired state, tree, diff
Loading
Loading