Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
95d3866
feat(dotfiles): add merge entries that own only some keys of a config…
jdx Oct 6, 2026
dd5fc90
fix(dotfiles): reject conflicting merge entries and bad sources befor…
jdx Oct 6, 2026
5492a44
fix(dotfiles): detect conflicting merge entries that reach one file b…
jdx Oct 6, 2026
45d7181
fix(dotfiles): fold case when comparing merge targets for conflicts
jdx Oct 6, 2026
823030d
fix(dotfiles): only treat case variants of merge targets as one file …
jdx Oct 6, 2026
40049f5
fix(dotfiles): catch case-variant merge targets that share an existin…
jdx Oct 6, 2026
20682c5
fix(dotfiles): treat missing merge targets under one directory as one…
jdx Oct 6, 2026
73476da
fix(dotfiles): stop guessing case behavior for missing merge targets
jdx Oct 6, 2026
1ed86a3
fix(dotfiles): check merge conflicts against entries a target filter …
jdx Oct 6, 2026
b102e41
fix(dotfiles): compare only applied merge entries with their siblings
jdx Oct 6, 2026
303b646
fix(dotfiles): refuse merge/block conflicts and whole-file keys on me…
jdx Oct 6, 2026
2d2baa2
fix(dotfiles): stop comparing merge entries with blocks
jdx Oct 6, 2026
1e696ef
fix(dotfiles): validate incoming merge entries and refuse mixed formats
jdx Oct 6, 2026
2338181
fix(dotfiles): check merge format conflicts without rendering
jdx Oct 6, 2026
d7d798e
fix(dotfiles): ignore blocked siblings in the merge format check
jdx Oct 6, 2026
2df696a
fix(dotfiles): don't open unrelated targets in the merge format check
jdx Oct 6, 2026
6e575bd
fix(dotfiles): compare merge targets without opening them
jdx Oct 6, 2026
ff810de
fix(dotfiles): never render unselected template merge entries
jdx Oct 6, 2026
b3d4ad6
docs(dotfiles): state when template merge conflicts are detected
jdx Oct 6, 2026
a985977
fix(schema): reject every whole-file key on a dotfiles merge entry
jdx Oct 6, 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
101 changes: 101 additions & 0 deletions crates/mise-util/src/structured_merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,83 @@ fn parse(format: Format, text: &str, what: &str) -> Result<Doc> {
})
}

/// Dotted paths that `a` and `b` both set, to different values. Two sources
/// that agree on a key, or that set different keys of the same table, do not
/// conflict.
pub fn conflicts(format: Format, a: &str, b: &str) -> Result<Vec<String>> {
let mut paths = vec![];
match (parse(format, a, "source")?, parse(format, b, "source")?) {
(Doc::Json(a), Doc::Json(b)) => json_conflicts(&a, &b, "", &mut paths),
(Doc::Toml(a), Doc::Toml(b)) => toml_conflicts(&a, &b, "", &mut paths),
(Doc::Yaml(a), Doc::Yaml(b)) => yaml_conflicts(&a, &b, "", &mut paths),
_ => unreachable!("both sides are parsed as the same format"),
}
Ok(paths)
}

fn child_path(prefix: &str, key: &str) -> String {
if prefix.is_empty() {
key.to_string()
} else {
format!("{prefix}.{key}")
}
}

fn json_conflicts(a: &Value, b: &Value, prefix: &str, paths: &mut Vec<String>) {
let (Value::Object(a), Value::Object(b)) = (a, b) else {
return;
};
for (key, av) in a {
let Some(bv) = b.get(key) else { continue };
let path = child_path(prefix, key);
if av.is_object() && bv.is_object() {
json_conflicts(av, bv, &path, paths);
} else if av != bv {
paths.push(path);
}
}
}

fn toml_conflicts(a: &toml::Table, b: &toml::Table, prefix: &str, paths: &mut Vec<String>) {
for (key, av) in a {
let Some(bv) = b.get(key) else { continue };
let path = child_path(prefix, key);
match (av, bv) {
(toml::Value::Table(av), toml::Value::Table(bv)) => {
toml_conflicts(av, bv, &path, paths);
}
_ if av != bv => paths.push(path),
_ => {}
}
}
}

fn yaml_conflicts(
a: &serde_yaml::Mapping,
b: &serde_yaml::Mapping,
prefix: &str,
paths: &mut Vec<String>,
) {
for (key, av) in a {
let Some(bv) = b.get(key) else { continue };
let name = match key {
serde_yaml::Value::String(name) => name.clone(),
other => serde_yaml::to_string(other)
.unwrap_or_default()
.trim()
.to_string(),
};
let path = child_path(prefix, &name);
match (av, bv) {
(serde_yaml::Value::Mapping(av), serde_yaml::Value::Mapping(bv)) => {
yaml_conflicts(av, bv, &path, paths);
}
_ if av != bv => paths.push(path),
_ => {}
}
}
}

fn is_comments_only(text: &str) -> bool {
text.lines()
.all(|l| l.trim().is_empty() || l.trim_start().starts_with('#'))
Expand Down Expand Up @@ -464,6 +541,30 @@ path = \"/Applications/X.app\"
assert!(!contains(Format::Yaml, target, "env:\n 2: three\n").unwrap());
}

#[test]
fn sources_conflict_only_on_different_values_for_one_key() {
let conflicts = |a, b| conflicts(Format::Toml, a, b).unwrap();
assert_eq!(
conflicts("m = 1\n[t]\nx = 1\ny = 2\n", "m = 2\n[t]\nx = 9\ny = 2\n"),
vec!["m".to_string(), "t.x".to_string()]
);
assert!(conflicts("m = 1\n[t]\nx = 1\n", "m = 1\n[t]\ny = 2\n").is_empty());
// a table against a scalar is a conflict too
assert_eq!(conflicts("t = 1\n", "[t]\nx = 1\n"), vec!["t".to_string()]);
}

#[test]
fn conflicts_are_found_in_every_format() {
assert_eq!(
conflicts(Format::Json, r#"{"a": {"b": 1}}"#, r#"{"a": {"b": 2}}"#).unwrap(),
vec!["a.b".to_string()]
);
assert_eq!(
conflicts(Format::Yaml, "a:\n b: 1\n1: x\n", "a:\n b: 2\n1: y\n").unwrap(),
vec!["a.b".to_string(), "1".to_string()]
);
}

#[test]
fn merging_twice_changes_nothing() {
for (format, target, source) in [
Expand Down
57 changes: 54 additions & 3 deletions docs/dotfiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -825,9 +825,9 @@ alias la='ls -la'
"~/.gitconfig/identity" = { source = "snippets/git-identity.tmpl", template = "tera" }
```

For edit entries, `source` is paired with `template = "tera"` to make the
entry unambiguously an edit. A table with only `source` is a whole-file
entry using `dotfiles.default_mode`.
For edit entries, `source` is paired with `template = "tera"` or
[`merge = true`](#merge) to make the entry unambiguously an edit. A table with
only `source` is a whole-file entry using `dotfiles.default_mode`.

A `block` is delimited by marker comments in the target file, named by the
entry's id:
Expand All @@ -854,6 +854,57 @@ Running apply again leaves an existing match wherever it is. Other bytes,
including line endings, stay unchanged. The value must be a single line;
use a block for multi-line content.

### Setting some keys of a config file {#merge}

Some applications write their own state into the same file that holds the
settings you care about: Codex rewrites `~/.codex/config.toml`, Claude Code
rewrites `~/.claude/settings.json`, and other tools re-serialize a YAML file
on every change. A `symlink` or `copy` entry fights them for the whole file,
and a `block` can't survive an application that rewrites the file.

A `merge` entry owns only the keys in its source. Everything else in the file
stays the application's:

```toml
[dotfiles]
"~/.codex/config.toml/shared" = { source = "codex/shared.toml", merge = true }
"~/.claude/settings.json/shared" = { source = "claude/shared.json", merge = true }
"~/.omp/agent/config.yml/shared" = { source = "omp/shared.yml", merge = true }
```

The source holds the keys you want in the same format as the target, which
mise infers from the target's `.json`, `.toml`, `.yaml`, or `.yml` extension.
With `codex/shared.toml` containing:

```toml
model = "gpt-5"

[tui]
theme = "light"
```

applying sets `model` and `tui.theme` and leaves the rest of
`~/.codex/config.toml` as it is, including `[plugins.*]` tables and comments
the application wrote. A missing target is created from the source.

- A table or object that exists on both sides merges recursively. Any other
value, including an array, is replaced by the source's value.
- Keys that only the target has are never changed, and keys you remove from
the source are not removed from the target.
- `mise dot status` and `mise dot diff` look only at the keys in the source,
so keys the application adds never show up as drift.
- TOML and YAML are edited in place, so comments, key order, and the
formatting of untouched keys stay. JSON has no comments; mise keeps key
order and the file's indentation, and rewrites the file only when an owned
key differs. A target that is not valid JSON, TOML, or YAML (including JSON
with comments) is reported and never overwritten.
- Two merge entries for one file that set the same key to different values are
refused instead of fighting over it. mise does not compare a merge with a
`block` or `line` edit of the same file, so don't have both own one key.
- `template = "tera"` renders the source first, like other edit entries.
- `mise dot unapply` leaves merged keys in place, because the application may
have changed them since.

## How configuration is applied {#semantics}

- Entries merge across the [config hierarchy](/configuration.html).
Expand Down
Loading
Loading