Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Merges 23 commits from
developfor the v0.3.0 release. The[Unreleased]CHANGELOG section has been cut as0.3.0.Highlights:
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, soman matenever worked for installed users).#importfile attribute for files an application owns and rewrites, such as~/.claude/settings.json.splitListfailed to render.mate status/mate apply2.4–11x faster — both computed package "extras" that neither reports.mate apply <path>andmate apply -s <source>.#onchangefixed to trigger on source changes rather than edits to the script itself.apply,clean,status,diffandcheck.See CHANGELOG.md for the full list.
Breaking changes
Version stays pre-1.0 because these need a major bump under strict semver:
mate statusandmate diffis now a file/path filter only.mate status nvimno longer matches a whole source — usemate status -s nvim. A positional naming a source now errors with that suggestion.--forceor--no-scripts. Scripts are now confirmed individually, and with no terminal to prompt on they are skipped with a warning rather than run silently..mateignorefiles are no longer supported (useignore:inmate.yaml) — carried over from 0.2.0.Release process fixes included
^docs:, which would have silently dropped this release's largest change.latesttag 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 whylatestand 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 detectionmake lint— 0 issuesmake docs— no drift; generation verified deterministic across runsmake build-all— darwin/arm64 and linux/amd64 buildgoreleaser check— config valid (brews:deprecation noted, still functional)0.3.0,v0.3.0, an older version, a nonexistent version, and no argument#importlifecycle re-verified end-to-end on macOS and ArchNote that CI has never run on
developbefore this release — the workflow wasbranches: [main]only, and now includesdevelop. This PR is the first time these commits are checked by CI.🤖 Generated with Claude Code