Make config writes atomic and surface a diff when they fail#536
Merged
bmcnama-shopify merged 1 commit intomainfrom Apr 30, 2026
Merged
Make config writes atomic and surface a diff when they fail#536bmcnama-shopify merged 1 commit intomainfrom
bmcnama-shopify merged 1 commit intomainfrom
Conversation
joshheinrichs-shopify
approved these changes
Apr 29, 2026
Contributor
joshheinrichs-shopify
left a comment
There was a problem hiding this comment.
lgtm aside from the perms thing on new files thanks brandon!
Make `CLI::Kit::Config` writes atomic (tmpfile + rename) and surface a
diff of only the keys that changed when the write fails, so users whose
`~/.config/<tool>/config` is read-only — for example a nix/home-manager-
managed symlink into `/nix/store` — can see exactly what the tool was
trying to change instead of just getting a bare `Errno::EACCES`.
Atomic writes
-------------
`write_config` now writes to a `Tempfile` in the same directory and
renames it into place, so readers never observe a partial write. The
existing file mode is preserved across the rename, and new configs are
created with the umask-adjusted default (`0o666 & ~File.umask`,
matching `open(2)`); `Tempfile` defaults to `0o600`, which would
otherwise both silently tighten permissions on an existing config and
create new configs with that more restrictive mode.
Symlink preservation
--------------------
When the config path is a symlink, the target is resolved before
writing so the rename replaces the real file at the end of the chain
instead of clobbering the symlink with a regular file. This preserves
nix/home-manager-managed configs and surfaces `EACCES`/`EROFS`
naturally when the target is read-only, instead of silently replacing
a managed symlink with a local copy that future config-management
updates would stop applying to.
ConfigWriteError
----------------
`ConfigWriteError` is a new error class raised on `EACCES`, `EPERM`,
and `EROFS`. It inherits from `SystemCallError` so existing
`rescue SystemCallError` handlers still match, and it preserves the
original errno from the wrapped exception. The message includes a
section-scoped diff of only the keys that actually changed; unchanged
keys (which may include sensitive values such as API tokens) are
deliberately omitted so the failure message can never leak secrets
through stderr or exception reports.
Example error output:
Could not write to /Users/you/.config/tool/config: Permission denied
Attempted changes (unchanged keys omitted):
[hooks]
- path_check_enabled = true
+ path_check_enabled = false
Tests cover atomic-write correctness, tmpfile cleanup, new-file mode
following the umask, preservation of an existing file's mode, diff on
a read-only directory, new-section diff, secret-leak protection,
`EROFS` (read-only filesystem), `rescue SystemCallError`
compatibility, and symlink preservation on both successful and failing
writes.
f6365ec to
377b8d6
Compare
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.
Make
CLI::Kit::Configwrites atomic (tmpfile + rename) and surface adiff of only the keys that changed when the write fails, so users whose
~/.config/<tool>/configis read-only — for example a nix/home-manager-managed symlink into
/nix/store— can see exactly what the tool wastrying to change instead of just getting a bare
Errno::EACCES.Atomic writes
write_confignow writes to aTempfilein the same directory andrenames it into place, so readers never observe a partial write. The
existing file mode is preserved across the rename, and new configs are
created with the umask-adjusted default (
0o666 & ~File.umask,matching
open(2));Tempfiledefaults to0o600, which wouldotherwise both silently tighten permissions on an existing config and
create new configs with that more restrictive mode.
Symlink preservation
When the config path is a symlink, the target is resolved before
writing so the rename replaces the real file at the end of the chain
instead of clobbering the symlink with a regular file. This preserves
nix/home-manager-managed configs and surfaces
EACCES/EROFSnaturally when the target is read-only, instead of silently replacing
a managed symlink with a local copy that future config-management
updates would stop applying to.
ConfigWriteError
ConfigWriteErroris a new error class raised onEACCES,EPERM,and
EROFS. It inherits fromSystemCallErrorso existingrescue SystemCallErrorhandlers still match, and it preserves theoriginal errno from the wrapped exception. The message includes a
section-scoped diff of only the keys that actually changed; unchanged
keys (which may include sensitive values such as API tokens) are
deliberately omitted so the failure message can never leak secrets
through stderr or exception reports.
Example error output:
Tests cover atomic-write correctness, tmpfile cleanup, new-file mode
following the umask, preservation of an existing file's mode, diff on
a read-only directory, new-section diff, secret-leak protection,
EROFS(read-only filesystem),rescue SystemCallErrorcompatibility, and symlink preservation on both successful and failing
writes.