Skip to content

release: v0.3.0 - #38

Merged
subbeh merged 23 commits into
mainfrom
develop
Aug 18, 2026
Merged

subbeh merged 23 commits into
mainfrom
develop

Conversation

@subbeh

@subbeh subbeh commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Summary

Merges 23 commits from develop for the v0.3.0 release. The [Unreleased] CHANGELOG section has been cut as 0.3.0.

Highlights:

  • Documentation published in-repo under docs/ — nine hand-written guides plus a generated command reference, kept fresh by CI, a test, and a pre-commit hook. Man pages dropped (they were never actually shipped, so man mate never worked for installed users).
  • #import file attribute for files an application owns and rewrites, such as ~/.claude/settings.json.
  • Sprig template library (~200 functions). Previously only 9 functions existed, so a template using splitList failed to render.
  • mate status/mate apply 2.4–11x faster — both computed package "extras" that neither reports.
  • Scoped apply — mate apply <path> and mate apply -s <source>.
  • Script confirmation prompts with descriptions, and #onchange fixed to trigger on source changes rather than edits to the script itself.
  • Numerous sudo/permission fixes across apply, clean, status, diff and check.

See CHANGELOG.md for the full list.

Breaking changes

Version stays pre-1.0 because these need a major bump under strict semver:

  1. The positional argument to mate status and mate diff is now a file/path filter only. mate status nvim no longer matches a whole source — use mate status -s nvim. A positional naming a source now errors with that suggestion.
  2. Automated runs must pass --force or --no-scripts. Scripts are now confirmed individually, and with no terminal to prompt on they are skipped with a warning rather than run silently.
  3. .mateignore files are no longer supported (use ignore: in mate.yaml) — carried over from 0.2.0.

Release process fixes included

  • Release notes now come from CHANGELOG.md. Goreleaser's generated notes excluded ^docs:, which would have silently dropped this release's largest change.
  • The latest tag update now survives a partial goreleaser failure. v0.2.1 published its release and Homebrew formula, then aborted because the AUR was down for maintenance — which is why latest and the AUR are both still on older versions. Tagging v0.3.0 also brings the AUR back up to date.

Test plan

  • make test — all packages pass with race detection
  • make lint — 0 issues
  • make docs — no drift; generation verified deterministic across runs
  • make build-all — darwin/arm64 and linux/amd64 build
  • goreleaser check — config valid (brews: deprecation noted, still functional)
  • CHANGELOG extraction verified for 0.3.0, v0.3.0, an older version, a nonexistent version, and no argument
  • Pre-commit hook exercised across 5 scenarios (blocks, recovers, passes pre-staged, skips non-CLI, still enforces CHANGELOG)
  • Docs tests verified to fail when a feature is undocumented, not just to pass
  • #import lifecycle re-verified end-to-end on macOS and Arch

Note that CI has never run on develop before this release — the workflow was branches: [main] only, and now includes develop. This PR is the first time these commits are checked by CI.

🤖 Generated with Claude Code

subbeh added 23 commits August 4, 2026 10:55
mate edit only searched the scanned source tree, so files listed under
include: or var_files: (e.g. .matedata/secrets.yaml#encrypted) failed
with "file not found" -- even though tab completion offered them.

Path resolution is now strict and CLI-like: absolute, or relative to the
current directory. Any file under the source directory is editable, and
target paths resolve to their source file (mate never edits deployed
files in place). Fuzzy suffix matching is removed.

Completion is replaced with plain filesystem completion, so suggestions
can no longer drift from what resolution accepts.

Also preserves the original file mode when re-encrypting and writes the
plaintext temp file as 0600.
Three user-facing fixes since v0.2.1 were missing CHANGELOG entries
(clean sudo removal, provides-package detection, source completion,
wrapped permission errors).

Add an explicit mandatory-CHANGELOG section to CLAUDE.md with a
pre-commit check, since this step was repeatedly skipped.
Blocks commits that stage non-test files under internal/ or cmd/
without staging CHANGELOG.md. Test-only and docs-only changes are
exempt; --no-verify bypasses for genuinely internal changes.

Tracked in .githooks/ so it survives fresh clones. Requires a one-time
`git config core.hooksPath .githooks` (documented in CLAUDE.md).
mate status showed pending files, orphans, scripts, and secrets, but gave
no indication that declared packages were not installed -- that only
surfaced during apply.

Adds a "Missing packages" section grouped by manager. Package manager
errors are ignored so an unavailable manager never fails status. The
--short statusline format is unchanged.
Scripts ran silently during apply with no per-script visibility or way
to decline. They also had no way to describe their purpose, so intent
had to be inferred from the filename.

Adds a confirmation prompt for each due automatic script offering
[y]es/[n]o/[a]ll/[q]uit. Declining does not record a run, so a skipped
`once` script is offered again next apply rather than being silently
dismissed. Quit aborts the apply, since `before` scripts may be
prerequisites for the changes that follow.

Scripts can now declare `# Description: <text>` in their first 10
lines (case-insensitive, read raw without template rendering). The
description appears in the prompt, `scripts list`, and `status`.

--force auto-confirms; --no-scripts skips silently for automation.
With no TTY, scripts are skipped and a warning names them and both
flags -- a behavior change for existing automation, chosen so
unreviewed code never runs unattended.
mate managed matched any target ending in the filter string, so running
"mate managed config" from ~/.ssh returned four unrelated files
(~/.ssh/config, ~/.config/git/config, ~/.aws/config, and
~/.config/waybar/config) with no way to disambiguate.

A filter that resolves to an existing file (absolute, tilde, or relative
to the current directory) now matches only the entry with that exact
target or source path. Filters that do not resolve to a file are still
matched loosely, so "mate managed nvim" continues to list a whole
source.

This makes a path an unambiguous lookup, which lets external tooling
resolve a deployed file back to its source. Consistent with the strict
path resolution mate edit already uses.
Nothing exposed the resolved source directory, so external tooling had
to reimplement mate's resolution order (--config, then STATEMATE_DIR,
then source_dir in the local config, then cwd) to locate the repo.

Prints the path bare so it works directly in command substitution:

  cd "$(mate config source-dir)"

On failure nothing is written to stdout and the exit code is non-zero,
so $(...) yields an empty string rather than an error message.

Added under a `config` parent to leave room for further resolved values
without reshuffling the top-level command list.
The confirmation prompt only offered [n]o, which defers to the next
apply, so there was no way to permanently dismiss a script you never
want to run.

[s]kip records a run without executing, so the script is not offered
again while that record stands. It is not offered for 'always' scripts,
whose runs are never recorded and for which "mark as done" would
silently do nothing.

Labelled "mark as done" rather than "forever" because the effect follows
the frequency: permanent for once, until the content changes for
onchange, until the interval elapses for daily/weekly/monthly.

A marked script still shows in 'mate scripts list' as done and can be
run manually with 'mate scripts run'.
mate mutates the real filesystem and prompts on a TTY, which makes
ad-hoc verification give wrong answers in two ways:

  - The state DB lives under $XDG_DATA_HOME, so without isolating it
    mate opens the developer's real DB and reports their actual
    dotfiles as orphans.
  - A pipe is not a TTY, so `printf 'y\n' | mate apply` skips every
    script and silently exercises the non-interactive branch instead
    of the confirmation prompts.

driver.py scaffolds a disposable repo (plain file, #template,
#perm-r:755, per-source packages, two scripts), isolates all state, and
can run mate under a real pseudo-terminal. `smoke` asserts 13
behaviours end to end and exits non-zero on failure.

SKILL.md documents only commands verified in this session, including
gotchas found while building it: --force also auto-confirms package
installs (an uninstallable package then aborts the apply), a source's
packages ignore its profile: key, and scratch paths need symlinks
resolved for strict path matching.
runAdd built the source list with profile.ResolveSources but showed
cfg.Sources in the picker, then indexed into the resolved list. With a
profile contributing sources the two lists differ in length, so a
selection mapped to the wrong source -- and profile-provided sources
could not be chosen at all.

Verified against the previous binary: the picker showed only "app"
where it now offers "app" and "bin".
#onchange compared the script's own content hash, so it only reran when
you edited the script itself. That inverts the intent: a script like
arch/.matescripts/00-env_reload.sh#onchange#after exists to reload the
environment when the arch source changes, and effectively never fired.

An #onchange script now runs when its own source has pending changes --
the files mate status lists for the source it lives in. A repo-root
script has no owning source, so any pending change triggers it.

Editing an #onchange script is no longer a trigger; use
mate scripts run <name> to run one on demand. Runs are still recorded so
scripts list shows a timestamp and [s]kip keeps working, but the record
no longer decides whether the script runs.

Callers pass the changed-source set into ShouldRun rather than having it
rescan per script. apply computes it before applying, so #before and
#after see the same set. scripts list computes it too and now uses the
shared scheduler instead of duplicating the rule, so its status column
cannot disagree with apply.
Descriptions were printed on their own indented line under each row,
breaking the table: rows were no longer one-per-script and the
continuation line aligned with nothing.

scripts list now renders via tablewriter (as mate managed already does),
with DESCRIPTION as a column. Column widths size to their content, so a
repo with short names no longer pays for a fixed 30-char NAME column.

Long descriptions are truncated with an ellipsis to fit the terminal.
Two details worth noting:

  - tablewriter wraps by default, which would reintroduce the very
    continuation lines this replaces; WrapTruncate is required.
  - Its global MaxWidth spreads a shortfall across every column, cutting
    names and leaving only a few characters for the description. The
    description is therefore trimmed directly, leaving other columns
    intact -- on an 80-col terminal that yields 27 usable characters
    instead of 7.

With no terminal (piped, redirected, CI) nothing is truncated, so
descriptions stay complete and greppable.

The leading "-" marker for profile-inactive scripts is dropped as
redundant: those rows already show "n/a" under STATUS.
mate apply was all-or-nothing: iterating on one config file meant
running every phase across every source.

  mate apply <path>       applies matching files only -- no scripts, no
                          packages, no secret fetch
  mate apply -s <source>  applies that source's files, runs its scripts,
                          and prompts for its packages

The positional argument is always a file/path filter and --source is the
only way to select a source, because the two do different amounts of
work. Inferring which was meant from a bare word would silently skip a
source's scripts and packages while appearing to succeed. A positional
that names a source now errors with a --source suggestion.

Repo-root scripts do not run under --source: they apply to the whole
repository, so running them for one source would overreach. Package
filtering uses PackageStatus.Sources, which already records the
contributing source.

status and diff get the same --source flag and the same strict
positional rule, so all three commands interpret arguments identically.
This is a behaviour change for status/diff, whose positional previously
prefix-matched source names too.

Also adds the missing Args constraint to diff, which silently accepted
extra arguments.
A scoped apply filtered files, scripts, and packages but still passed the
full source path list to secret discovery. `mate apply -s env` therefore
walked every source's templates and tried to fetch secrets they
reference, failing on a source the user had not asked to apply:

  $ mate apply -s env
  Fetching 2 missing secrets...
  Error: fetching secrets: ... extracting bitwarden:hetzner.com:...

Secret discovery and script discovery walk the source paths directly
rather than going through the tree, so those paths are now scoped too. A
file-scoped run performs no discovery at all, since it deploys files and
nothing else.

Verified against the previous binary, which reached for the unrelated
source's secret where this one does not; unscoped runs still discover
every source.
The directory-creation loop called os.MkdirAll directly, with none of the
sudo fallback applyFile already had for the parent directories of
individual files. A source mapping a root-owned location failed outright:

  $ mate apply
  Error: creating directory /etc/restic: mkdir /etc/restic: permission denied

Directories needing elevation now go through sudoMkdir, and owner/group
attributes are applied to directories -- previously only files got them,
so an #owner-r:root directory received the right mode but the wrong
owner.

An existing directory with no perm/owner/group attribute is now skipped
entirely. Without that, a mapped root such as etc: /etc would be handed
to sudoMkdir, which unconditionally chmods, so every apply would run
`sudo chmod 755 /etc` for a directory the user never asked to change.

Verified against the previous binary, which reproduces the reported
permission-denied error where this one escalates instead.
After writing a file, applyFile hashes the target to store it in the
state DB. For a file it had just written via sudo -- root-owned, or mode
0600 like a rendered secret -- that read fails as the invoking user, so
the apply reported failure for a write that had actually succeeded:

  Error: applying .../restic/password#template#perm:600:
  opening file: open /etc/restic/password: permission denied

Hashing the target now falls back to elevated access, via a shared
hashTarget helper. importFile and recordState read the target the same
way and had the same flaw, so all three now use it.

When elevated access is also unavailable the original permission error is
reported with the path, rather than a bare EACCES from inside HashFile.
Templates had only 9 hand-picked functions, so anything else failed at
parse time with `function "splitList" not defined` -- text/template has
no string-splitting function of its own. Several of the existing names
(default, required, indent, base64Decode) are sprig names, which made
the funcMap look sprig-flavoured while actually being a small subset.

Statemate's functions are layered on top, so they win on a name clash
and existing templates keep working. Three collide: env, default and
indent.

Secret discovery had a third funcMap of its own, holding functions that
were never in the real render path (contains, join, upper). It now
reuses the render function set with the side-effecting ones stubbed. A
template discovery could not parse was skipped silently, so a file
using both bitwarden and a sprig function never had its secrets fetched
and the apply then failed on a cache miss.
mate status and mate apply both called ComputeSync, which listed every
explicitly-installed package to find ones no source declares -- then
discarded the result, since neither command reports extras. That single
step was 95% of the runtime: `brew leaves` costs about a second on
macOS, and `pacman -Qen` plus `paru -Qmtt` about 0.7s on Arch.

Extras are now behind WithExtras, passed only by `packages status --all`
and `packages apply --prune`.

Measured on real repos:
  brew   status 1.10s -> 0.10s, apply --dry-run 1.15s -> 0.12s
  pacman status 1.15s -> 0.48s, apply --dry-run 1.23s -> 0.53s
  packages status 1.05s -> 0.38s (Arch), 1.02s -> 0.06s (brew)

Output is unchanged apart from `packages status` without --all, which
can no longer say how many extras exist -- producing that count is the
expensive call -- so it prints a static --all hint instead.

The remaining Arch cost is `pacman -Qm` for the declared AUR packages,
which takes ~300ms regardless of how many names it is given. That is
inherent to the foreign-package check, not something batching avoids.
Lead with what identifies a script (ORDER, NAME, SOURCE) before how it
is scheduled (FREQUENCY, TIMING), then STATUS and DESCRIPTION.

ORDER moves to the first column and keeps its right alignment, so the
alignment slice moves with it.
Some managed files are owned by the application that reads them:
~/.claude/settings.json gets rewritten whenever a setting changes. Until
now every apply stopped and asked whether to overwrite or import, and
the answer was always import. For an #encrypted file the drift was also
invisible in status output.

#import makes the target authoritative:

  source unchanged, target changed -> import, no prompt
  source changed, target unchanged -> deploy, as usual
  target missing                   -> deploy (so bootstrapping works)
  both changed                     -> conflict prompt

The both-changed case deliberately keeps prompting. Letting the target
win there would silently discard an intentional source edit, which is
the one case where the user's own change is at stake.

status marks a pending import with '<' and counts it as <N in --short.
diff reverses direction for these files, decrypting an encrypted source
first so the comparison is plaintext. Combining #import with #template
is rejected: importing would overwrite the template with its own
rendered output.

Also fixes two things this exposed: importFile could not read a target
needing elevated access, and --dry-run reported imports as "0 files
would be applied".
Documentation was effectively unpublished. `make docs` wrote man pages and
markdown into docs/, which was gitignored; the release workflow never ran it and
goreleaser bundled only LICENSE and README. So `man mate` failed for everyone who
installed via brew or the AUR, and the generated reference had drifted far enough
to still document `mate run` and `mate remove`, commands that no longer exist.

The only reference for file formats was a section of README.md, because cobra can
only generate per-command help. Attributes, config keys, template functions and
script naming had nowhere else to live -- and several features had never been
documented at all: variable_commands, generate, the var and bitwardenAttachment
template functions, and every script environment variable.

Publish hand-written guides under docs/, rendered by GitHub with no build step,
and generate the command reference into docs/commands/ from the cobra tree.

Drop man pages. They were never shipped, and cobra's roff output only duplicated
`mate <command> --help`. That also removes the release plumbing they would have
needed in the archive, the brew formula and the AUR package.

Keep it fresh with three layers, since a checklist alone is how it went stale:

  - CI regenerates docs/commands/ and fails on any diff, so a help-text change
    must be regenerated in the same commit. Generation is now deterministic
    (DisableAutoGenTag), or the timestamp footer would fail the check daily.
  - internal/cli/docs_test.go fails when a file attribute, config key, template
    function, script frequency or environment variable is not mentioned in the
    guides. Config keys come from reflection over the yaml tags, so adding a
    field is enough to trip it. This is what #import would have hit.
  - The pre-commit hook regenerates docs/commands/ when internal/cli/ changes and
    rejects the commit if the result is not staged.

Writing the guides surfaced a documentation bug: #symlink was described as
"symlink to the source instead of copying" in README.md, which is backwards. It
reproduces a symlink -- the source must itself BE one, and its destination is
recreated at the target. Applying it to a regular file fails outright.

Also enable CI on develop, where it previously never ran.
Cut the [Unreleased] section as 0.3.0.

Take release notes from CHANGELOG.md instead of generating them from commit
subjects. Goreleaser's generated notes excluded ^docs:, ^test:, ^ci: and ^chore:,
which would have silently dropped this release's largest change -- the whole
documentation rewrite, committed as "docs: ...". The CHANGELOG is already written
from the user's perspective, so use it directly via --release-notes.

Make the latest-tag update survive a partial goreleaser failure. v0.2.1 published
its GitHub release and Homebrew formula, then aborted because the AUR was down for
maintenance; the steps that move the `latest` tag never ran, which is why `latest`
and the AUR are both still on older versions. Those steps now run even when
goreleaser fails, guarded on archives actually existing so a genuine build failure
does not publish an empty release.
@subbeh
subbeh merged commit a256b05 into main Aug 18, 2026
8 checks passed
@subbeh
subbeh deleted the develop branch August 18, 2026 01:20
@subbeh
subbeh restored the develop branch August 18, 2026 01:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant