Inject declared items into config files that belong to another program.
One rule: apply what is provably ours, report what is not, never overwrite a disagreement.
cfggraft merge --key .env --fragment env.json --file ~/.config/app/settings.json --in-place
cfggraft region --marker PROFILE --body generated.md --file ~/.config/app/notes.md --in-place
A config file you want to manage from a repo is often not yours alone. The
application writes to it too — a model choice, a remembered permission, a UI
toggle. Owning the whole file (symlink, template render, cp) throws those
away. Owning nothing means the repo can't configure anything.
So you inject items and leave the rest. The awkward part is what to do when both sides have something to say about the same item, and no standard format expresses the answer:
| approach | what it does with an existing item |
|---|---|
| RFC 7396 JSON Merge Patch | overwrites, unconditionally |
| RFC 6902 JSON Patch | test + add can guard it, but re-applying add to an array duplicates |
ansible blockinfile |
text lines only, no structure |
chezmoi modify_ |
gives you a stdin→stdout hook; the policy is still yours to write (#219 is open) |
The unit is an item, not a file format. Managing config from above means declaring, per item, what its value should be. Whether that item is a key path in a JSON document or a marker-delimited span of a text file only changes how it is addressed, read, and written.
Deciding needs three values, not two:
| declared | what the repo says the value should be |
| prior | what cfggraft wrote on its last run |
| observed | what is in the target right now |
Without prior there is no way to tell a value cfggraft wrote itself from a value
the other program wrote, so every change to a declaration looks like a conflict
and nothing can ever be updated or withdrawn. With it:
| declared | ownership | observed | |
|---|---|---|---|
| yes | unknown | absent | add |
| yes | unknown | equal | skip — and adopt, recording ownership |
| yes | unknown | differs | refuse — not ours to claim |
| yes | ours | equal | skip |
| yes | ours | differs | update — the old value is provably ours |
| yes | diverged | any | refuse — someone changed what we wrote |
| yes | removed | absent | refuse — the deletion was deliberate |
| no | ours | present | retract — ours, and no longer declared |
| no | other | any | skip |
Two properties follow: nothing is written over a value unless that value is ours, and re-running an unchanged declaration produces only skips.
Ownership needs a record of what was written. Where it lives depends on what the target can carry.
region keeps it inline, as a digest in the begin marker — text can hold a
comment, so the whole contract stays visible in the file being managed:
hand-written text, yours
<!-- ===PROFILE=== generated by cfggraft; edit config/profile.md instead; sha256=9c91… -->
generated body
<!-- ===/PROFILE=== -->
more hand-written text, also yoursmerge keeps it in $XDG_STATE_HOME/cfggraft/priors.json (--prior-store<
8000
/code>
overrides). JSON has no comments, and adding a reserved key to another program's
document would be a change it never asked for and might reject.
The record is local to the machine holding the target, never to the repo holding the declaration. Lose it and every item reads as unowned, so the run degrades to add-only — a loss of capability, not of data.
Provenance is written only when the target is: with --in-place, and without
--check.
A declaration expands into one item per leaf, so each key is decided separately and a disagreement about one never blocks the rest.
Arrays are sets: list-valued settings are combined across config layers by the programs that read them, so an element is a membership statement rather than a position. Elements are addressed by value, never by index — which is what makes a withdrawn element findable later instead of accumulating forever.
$ cfggraft merge --key .permissions.deny --fragment deny.json \
--file ~/.config/app/settings.json --in-place --retract
add permissions.deny[#8dd3b1a5] "Bash(rm -rf:*)"
retract permissions.deny[#7c9f1e95] "Bash(rm:*)"
merge: added=1 updated=0 skipped=1 retracted=1 refused=0Anything the other program added itself is not in the record, so it is never walked and never touched.
A run decides only on items under its --key. Several declarations can manage
one file at different key paths: each leaves the others' items, and their
records, alone, even with --retract.
--fragment-key reads from a different path inside the fragment;
--fragment-root uses the whole document. The target's key order is preserved —
reordering someone else's file turns a one-line injection into a whole-file diff.
For a file a human also writes. The span is a single item, so the run is all-or-nothing; everything outside the markers is theirs.
--init appends the region when the marker is absent. --comment hash uses #
instead of <!-- --> for shell, toml and yaml.
A marker is a whole line in that comment style: optional indentation, the
comment opener, ===NAME=== or ===/NAME===, and for html the --> closing
the same line. Text after the token inside the comment is fine — that is where
the digest lives. A line that merely mentions the marker, in prose, in backticks
or mid-line, is not a marker, and neither is a marker in the other comment
style. Fenced code blocks are not tracked, so an example region in a file that
also holds the real one needs a different name; a file holding the same begin
marker twice is refused. Names are ASCII letters, digits, _, - and ..
--retract does not apply to region: the span is always declared, so there is
nothing for it to remove. Passing it is an error.
The marker name is a long-lived contract. Renaming it orphans every file carrying the old name: the span stops being found and a second region gets appended below the first.
A span with no recorded digest whose body differs from the declaration is
refused, because nothing shows the body was ever cfggraft's. Adopt it deliberately
with one --force, after which ordinary updates apply.
| code | meaning |
|---|---|
| 0 | applied, or already up to date |
| 1 | failure — bad input, unreadable file, missing marker without --init |
| 2 | drift — something cfggraft declined to overwrite. Nothing was written. |
2 is reserved for drift alone. A check that cannot tell the generator crashed from there is no difference is a green light that checks nothing.
| flag | |
|---|---|
--file PATH |
target; defaults to stdin. A missing file is an empty document, so the first run can create it. |
--in-place |
write back atomically (temp file in the same dir, then rename, original mode carried over). Skipped when nothing changed, so mtime stays put. |
--check |
report only; writes nothing anywhere. This is the drift check. |
--verbose |
also list what was already in place. |
--force |
overwrite values cfggraft cannot claim, discarding them. |
--retract |
remove items cfggraft wrote that are no longer declared. merge only. |
--prior-store PATH |
where to keep the record of what cfggraft has written. |
Without --in-place, the result goes to stdout — on drift, byte for byte the
input, so a caller that writes it anyway still cannot destroy anything.
| crate | what it is | dependencies |
|---|---|---|
cfggraft |
the library: the model, the decision, the adapters | 17 |
cfggraft-cli |
argument parsing, file I/O, reporting, exit status | 35 |
The library is the product and keeps the name; the command line is one of its
callers. Splitting them keeps clap and its proc-macro chain — syn, quote,
proc-macro2, clap_derive among the 18 crates of difference — out of the
build of anything that only wants the library.
cargo install cfggraft-cli is what installs it. The binary is still cfggraft.
The whole design is in the library's documentation, including the decision table, what a representation has to implement, and why values cross the port as text rather than through a shared structural type.
cargo doc -p cfggraft --openpolicy decides, port states the interface, adapter implements it,
engine walks the items, prior persists provenance. Adding a representation
means adding an adapter; no other module changes.
cargo install cfggraft-cliFrom a checkout, cargo install --path crates/cfggraft-cli. Either way the
binary is cfggraft.
cargo test -p cfggraft
cargo test -p cfggraft-cli
cargo clippy --all-targets -- -D warnings
cargo fmt --checkEarly. Published on crates.io as cfggraft and cfggraft-cli, versioned and
released together; CHANGELOG.md says what each version changed. The interface
may still move before 1.0.