use std::collections::{BTreeMap, BTreeSet};
use crate::types::{SecretEntry, Vault};
#[derive(Debug)]
pub struct MergeConflict {
pub field: String,
pub reason: String,
}
#[derive(Debug)]
pub struct MergeResult {
pub vault: Vault,
pub conflicts: Vec<MergeConflict>,
}
pub fn merge_vaults(base: &Vault, ours: &Vault, theirs: &Vault) -> MergeResult {
let mut conflicts = Vec::new();
let version = ours.version.clone();
let created = ours.created.clone();
let vault_name = ours.vault_name.clone();
let repo = ours.repo.clone();
let recipients = merge_recipients(base, ours, theirs, &mut conflicts);
let base_recip: BTreeSet<&str> = base.recipients.iter().map(String::as_str).collect();
let ours_recip: BTreeSet<&str> = ours.recipients.iter().map(String::as_str).collect();
let theirs_recip: BTreeSet<&str> = theirs.recipients.iter().map(String::as_str).collect();
let ours_changed_recipients = ours_recip != base_recip;
let theirs_changed_recipients = theirs_recip != base_recip;
let schema = merge_btree(
&base.schema,
&ours.schema,
&theirs.schema,
"schema",
&mut conflicts,
);
let secrets = merge_secrets(
base,
ours,
theirs,
ours_changed_recipients,
theirs_changed_recipients,
&mut conflicts,
);
let meta = ours.meta.clone();
let vault = Vault {
version,
created,
vault_name,
repo,
recipients,
schema,
secrets,
meta,
};
MergeResult { vault, conflicts }
}
fn merge_recipients(
base: &Vault,
ours: &Vault,
theirs: &Vault,
conflicts: &mut Vec<MergeConflict>,
) -> Vec<String> {
let base_set: BTreeSet<&str> = base.recipients.iter().map(String::as_str).collect();
let ours_set: BTreeSet<&str> = ours.recipients.iter().map(String::as_str).collect();
let theirs_set: BTreeSet<&str> = theirs.recipients.iter().map(String::as_str).collect();
let ours_added: BTreeSet<&str> = ours_set.difference(&base_set).copied().collect();
let theirs_added: BTreeSet<&str> = theirs_set.difference(&base_set).copied().collect();
let ours_removed: BTreeSet<&str> = base_set.difference(&ours_set).copied().collect();
let theirs_removed: BTreeSet<&str> = base_set.difference(&theirs_set).copied().collect();
let mut result: BTreeSet<&str> = base_set;
for pk in &ours_added {
if theirs_added.contains(pk) {
result.insert(pk);
} else {
result.insert(pk);
conflicts.push(MergeConflict {
field: format!("recipients.{}", &pk[..12.min(pk.len())]),
reason: "added on one side but not the other".into(),
});
}
}
for pk in &theirs_added {
if !ours_added.contains(pk) {
result.insert(pk);
conflicts.push(MergeConflict {
field: format!("recipients.{}", &pk[..12.min(pk.len())]),
reason: "added on one side but not the other".into(),
});
}
}
for pk in &ours_removed {
if theirs_removed.contains(pk) {
result.remove(pk);
} else {
conflicts.push(MergeConflict {
field: format!("recipients.{}", &pk[..12.min(pk.len())]),
reason: "removed on one side but not the other".into(),
});
}
}
for pk in &theirs_removed {
if !ours_removed.contains(pk) {
conflicts.push(MergeConflict {
field: format!("recipients.{}", &pk[..12.min(pk.len())]),
reason: "removed on one side but not the other".into(),
});
}
}
result.into_iter().map(String::from).collect()
}
fn merge_btree<V: PartialEq + Clone>(
base: &BTreeMap<String, V>,
ours: &BTreeMap<String, V>,
theirs: &BTreeMap<String, V>,
field_name: &str,
conflicts: &mut Vec<MergeConflict>,
) -> BTreeMap<String, V> {
let all_keys: BTreeSet<&str> = base
.keys()
.chain(ours.keys())
.chain(theirs.keys())
.map(String::as_str)
.collect();
let mut result = BTreeMap::new();
for key in all_keys {
let in_base = base.get(key);
let in_ours = ours.get(key);
let in_theirs = theirs.get(key);
match (in_base, in_ours, in_theirs) {
(None, None, Some(t)) => {
result.insert(key.to_string(), t.clone());
}
(None, Some(o), None) => {
result.insert(key.to_string(), o.clone());
}
(None, Some(o), Some(t)) => {
if o == t {
result.insert(key.to_string(), o.clone());
} else {
conflicts.push(MergeConflict {
field: format!("{field_name}.{key}"),
reason: "added on both sides with different values".into(),
});
result.insert(key.to_string(), o.clone());
}
}
(Some(_) | None, None, None) => {}
(Some(b), Some(o), None) => {
if o == b {
conflicts.push(MergeConflict {
field: format!("{field_name}.{key}"),
reason: "removed on one side, unchanged on the other".into(),
});
result.insert(key.to_string(), o.clone());
}
}
(Some(b), None, Some(t)) => {
if t == b {
conflicts.push(MergeConflict {
field: format!("{field_name}.{key}"),
reason: "removed on one side, unchanged on the other".into(),
});
result.insert(key.to_string(), t.clone());
}
}
(Some(b), Some(o), Some(t)) => {
let ours_changed = o != b;
let theirs_changed = t != b;
match (ours_changed, theirs_changed) {
(false, true) => {
result.insert(key.to_string(), t.clone());
}
(true, true) if o != t => {
conflicts.push(MergeConflict {
field: format!("{field_name}.{key}"),
reason: "modified on both sides with different values".into(),
});
result.insert(key.to_string(), o.clone());
}
_ => {
result.insert(key.to_string(), o.clone());
}
}
}
}
}
result
}
fn merge_secrets(
base: &Vault,
ours: &Vault,
theirs: &Vault,
ours_changed_recipients: bool,
theirs_changed_recipients: bool,
conflicts: &mut Vec<MergeConflict>,
) -> BTreeMap<String, SecretEntry> {
if ours_changed_recipients && !theirs_changed_recipients {
return merge_secrets_with_reencrypted_side(base, ours, theirs, "theirs", conflicts);
}
if theirs_changed_recipients && !ours_changed_recipients {
return merge_secrets_with_reencrypted_side(base, theirs, ours, "ours", conflicts);
}
if ours_changed_recipients && theirs_changed_recipients {
return merge_secrets_both_reencrypted(base, ours, theirs, conflicts);
}
merge_secrets_normal(base, ours, theirs, conflicts)
}
fn merge_secrets_normal(
base: &Vault,
ours: &Vault,
theirs: &Vault,
conflicts: &mut Vec<MergeConflict>,
) -> BTreeMap<String, SecretEntry> {
let all_keys: BTreeSet<&str> = base
.secrets
.keys()
.chain(ours.secrets.keys())
.chain(theirs.secrets.keys())
.map(String::as_str)
.collect();
let mut result = BTreeMap::new();
for key in all_keys {
let in_base = base.secrets.get(key);
let in_ours = ours.secrets.get(key);
let in_theirs = theirs.secrets.get(key);
match (in_base, in_ours, in_theirs) {
(None, None, Some(t)) => {
result.insert(key.to_string(), t.clone());
}
(None, Some(o), None) => {
result.insert(key.to_string(), o.clone());
}
(None, Some(o), Some(t)) => {
if o.shared == t.shared {
result.insert(key.to_string(), o.clone());
} else {
conflicts.push(MergeConflict {
field: format!("secrets.{key}"),
reason: "added on both sides (values may differ)".into(),
});
result.insert(key.to_string(), o.clone());
}
}
(Some(_) | None, None, None) => {}
(Some(b), Some(o), None) => {
conflicts.push(MergeConflict {
field: format!("secrets.{key}"),
reason: if o.shared == b.shared {
"removed on one side, unchanged on the other".into()
} else {
"modified on our side but removed on theirs".into()
},
});
result.insert(key.to_string(), o.clone());
}
(Some(b), None, Some(t)) => {
conflicts.push(MergeConflict {
field: format!("secrets.{key}"),
reason: if t.shared == b.shared {
"removed on one side, unchanged on the other".into()
} else {
"removed on our side but modified on theirs".into()
},
});
result.insert(key.to_string(), t.clone());
}
(Some(b), Some(o), Some(t)) => {
let ours_changed = o.shared != b.shared;
let theirs_changed = t.shared != b.shared;
let shared = match (ours_changed, theirs_changed) {
(false, true) => t.shared.clone(),
(true, true) => {
conflicts.push(MergeConflict {
field: format!("secrets.{key}"),
reason: "shared value modified on both sides".into(),
});
o.shared.clone()
}
_ => o.shared.clone(),
};
let scoped = merge_scoped(&b.scoped, &o.scoped, &t.scoped, key, conflicts);
result.insert(key.to_string(), SecretEntry { shared, scoped });
}
}
}
result
}
fn merge_scoped(
base: &BTreeMap<String, String>,
ours: &BTreeMap<String, String>,
theirs: &BTreeMap<String, String>,
secret_key: &str,
conflicts: &mut Vec<MergeConflict>,
) -> BTreeMap<String, String> {
let all_pks: BTreeSet<&str> = base
.keys()
.chain(ours.keys())
.chain(theirs.keys())
.map(String::as_str)
.collect();
let mut result = BTreeMap::new();
for pk in all_pks {
let in_base = base.get(pk);
let in_ours = ours.get(pk);
let in_theirs = theirs.get(pk);
match (in_base, in_ours, in_theirs) {
(None, None, Some(t)) => {
result.insert(pk.to_string(), t.clone());
}
(None, Some(o), None) => {
result.insert(pk.to_string(), o.clone());
}
(None, Some(o), Some(t)) => {
if o == t {
result.insert(pk.to_string(), o.clone());
} else {
conflicts.push(MergeConflict {
field: format!("secrets.{secret_key}.scoped.{pk}"),
reason: "scoped override added on both sides".into(),
});
result.insert(pk.to_string(), o.clone());
}
}
(Some(_) | None, None, None) => {}
(Some(b), Some(o), None) => {
if o != b {
conflicts.push(MergeConflict {
field: format!("secrets.{secret_key}.scoped.{pk}"),
reason: "scoped override modified on our side but removed on theirs".into(),
});
result.insert(pk.to_string(), o.clone());
}
}
(Some(b), None, Some(t)) => {
if t != b {
conflicts.push(MergeConflict {
field: format!("secrets.{secret_key}.scoped.{pk}"),
reason: "scoped override removed on our side but modified on theirs".into(),
});
result.insert(pk.to_string(), t.clone());
}
}
(Some(b), Some(o), Some(t)) => {
let ours_changed = o != b;
let theirs_changed = t != b;
match (ours_changed, theirs_changed) {
(false, true) => {
result.insert(pk.to_string(), t.clone());
}
(true, true) if o != t => {
conflicts.push(MergeConflict {
field: format!("secrets.{secret_key}.scoped.{pk}"),
reason: "scoped override modified on both sides".into(),
});
result.insert(pk.to_string(), o.clone());
}
_ => {
result.insert(pk.to_string(), o.clone());
}
}
}
}
}
result
}
fn merge_secrets_with_reencrypted_side(
base: &Vault,
reencrypted: &Vault,
other: &Vault,
other_label: &str,
conflicts: &mut Vec<MergeConflict>,
) -> BTreeMap<String, SecretEntry> {
let mut result = reencrypted.secrets.clone();
let all_keys: BTreeSet<&str> = base
.secrets
.keys()
.chain(other.secrets.keys())
.map(String::as_str)
.collect();
for key in all_keys {
let in_base = base.secrets.get(key);
let in_other = other.secrets.get(key);
match (in_base, in_other) {
(None, Some(entry)) => {
if result.contains_key(key) {
conflicts.push(MergeConflict {
field: format!("secrets.{key}"),
reason: format!(
"added on {other_label} side and on the side that changed recipients"
),
});
} else {
result.insert(key.to_string(), entry.clone());
}
}
(Some(_), None) => {
result.remove(key);
}
(Some(b), Some(entry)) => {
if entry.shared != b.shared {
conflicts.push(MergeConflict {
field: format!("secrets.{key}"),
reason: format!(
"modified on {other_label} side while recipients changed on the other"
),
});
}
}
(None, None) => {}
}
}
result
}
fn merge_secrets_both_reencrypted(
base: &Vault,
ours: &Vault,
theirs: &Vault,
conflicts: &mut Vec<MergeConflict>,
) -> BTreeMap<String, SecretEntry> {
let all_keys: BTreeSet<&str> = base
.secrets
.keys()
.chain(ours.secrets.keys())
.chain(theirs.secrets.keys())
.map(String::as_str)
.collect();
let mut result = BTreeMap::new();
for key in all_keys {
let in_base = base.secrets.get(key);
let in_ours = ours.secrets.get(key);
let in_theirs = theirs.secrets.get(key);
match (in_base, in_ours, in_theirs) {
(Some(_), Some(o), Some(_)) | (None, Some(o), None) => {
result.insert(key.to_string(), o.clone());
}
(Some(_), Some(_) | None, None) | (Some(_), None, Some(_)) | (None, None, None) => {}
(None, None, Some(t)) => {
result.insert(key.to_string(), t.clone());
}
(None, Some(o), Some(_)) => {
conflicts.push(MergeConflict {
field: format!("secrets.{key}"),
reason: "added on both sides while both changed recipients".into(),
});
result.insert(key.to_string(), o.clone());
}
}
}
result
}
#[derive(Debug)]
pub struct MergeDriverOutput {
pub result: MergeResult,
pub meta_regenerated: bool,
}
pub fn run_merge_driver(base: &str, ours: &str, theirs: &str) -> Result<MergeDriverOutput, String> {
use crate::vault;
let base_vault = vault::parse(base).map_err(|e| format!("parsing base: {e}"))?;
let ours_vault = vault::parse(ours).map_err(|e| format!("parsing ours: {e}"))?;
let theirs_vault = vault::parse(theirs).map_err(|e| format!("parsing theirs: {e}"))?;
let mut result = merge_vaults(&base_vault, &ours_vault, &theirs_vault);
let meta_regenerated = regenerate_meta(&mut result.vault, &ours_vault, &theirs_vault).is_some();
Ok(MergeDriverOutput {
result,
meta_regenerated,
})
}
pub fn regenerate_meta(merged: &mut Vault, ours: &Vault, theirs: &Vault) -> Option<String> {
use crate::{compute_mac, crypto, decrypt_meta, encrypt_value, parse_recipients, resolve_key};
use age::secrecy::ExposeSecret;
use std::collections::HashMap;
let secret_key = resolve_key().ok()?;
let identity = crypto::parse_identity(secret_key.expose_secret()).ok()?;
let default_meta = || crate::types::Meta {
recipients: HashMap::new(),
mac: String::new(),
mac_key: None,
github_pins: HashMap::new(),
};
let ours_meta = decrypt_meta(ours, &identity).unwrap_or_else(default_meta);
let theirs_meta = decrypt_meta(theirs, &identity).unwrap_or_else(default_meta);
let mut names = theirs_meta.recipients;
for (pk, name) in ours_meta.recipients {
names.insert(pk, name);
}
names.retain(|pk, _| merged.recipients.contains(pk));
let mac_key_hex = crate::generate_mac_key();
let mac_key = crate::decode_mac_key(&mac_key_hex).unwrap();
let mac = compute_mac(merged, Some(&mac_key));
let mut github_pins = theirs_meta.github_pins;
for (user, pins) in ours_meta.github_pins {
github_pins.insert(user, pins);
}
let meta = crate::types::Meta {
recipients: names,
mac,
mac_key: Some(mac_key_hex),
github_pins,
};
let recipients = parse_recipients(&merged.recipients).ok()?;
if recipients.is_empty() {
return None;
}
let meta_json = serde_json::to_vec(&meta).ok()?;
let encrypted = encrypt_value(&meta_json, &recipients).ok()?;
merged.meta = encrypted;
Some("meta regenerated".into())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{SchemaEntry, SecretEntry, VAULT_VERSION, Vault};
use std::collections::BTreeMap;
fn base_vault() -> Vault {
let mut schema = BTreeMap::new();
schema.insert(
"DB_URL".into(),
SchemaEntry {
description: "database url".into(),
example: None,
tags: vec![],
..Default::default()
},
);
let mut secrets = BTreeMap::new();
secrets.insert(
"DB_URL".into(),
SecretEntry {
shared: "base-cipher-db".into(),
scoped: BTreeMap::new(),
},
);
Vault {
version: VAULT_VERSION.into(),
created: "2026-01-01T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1alice".into(), "age1bob".into()],
schema,
secrets,
meta: "base-meta".into(),
}
}
#[test]
fn merge_no_changes() {
let base = base_vault();
let r = merge_vaults(&base, &base, &base);
assert!(r.conflicts.is_empty());
assert_eq!(r.vault.secrets.len(), 1);
assert_eq!(r.vault.recipients.len(), 2);
}
#[test]
fn merge_ours_adds_secret() {
let base = base_vault();
let mut ours = base.clone();
ours.secrets.insert(
"API_KEY".into(),
SecretEntry {
shared: "ours-cipher-api".into(),
scoped: BTreeMap::new(),
},
);
ours.schema.insert(
"API_KEY".into(),
SchemaEntry {
description: "api key".into(),
example: None,
tags: vec![],
..Default::default()
},
);
let r = merge_vaults(&base, &ours, &base);
assert!(r.conflicts.is_empty());
assert!(r.vault.secrets.contains_key("API_KEY"));
assert!(r.vault.schema.contains_key("API_KEY"));
assert_eq!(r.vault.secrets.len(), 2);
}
#[test]
fn merge_theirs_adds_secret() {
let base = base_vault();
let mut theirs = base.clone();
theirs.secrets.insert(
"STRIPE_KEY".into(),
SecretEntry {
shared: "theirs-cipher-stripe".into(),
scoped: BTreeMap::new(),
},
);
let r = merge_vaults(&base, &base, &theirs);
assert!(r.conflicts.is_empty());
assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
}
#[test]
fn merge_both_add_different_keys() {
let base = base_vault();
let mut ours = base.clone();
ours.secrets.insert(
"API_KEY".into(),
SecretEntry {
shared: "ours-cipher-api".into(),
scoped: BTreeMap::new(),
},
);
let mut theirs = base.clone();
theirs.secrets.insert(
"STRIPE_KEY".into(),
SecretEntry {
shared: "theirs-cipher-stripe".into(),
scoped: BTreeMap::new(),
},
);
let r = merge_vaults(&base, &ours, &theirs);
assert!(r.conflicts.is_empty());
assert!(r.vault.secrets.contains_key("API_KEY"));
assert!(r.vault.secrets.contains_key("STRIPE_KEY"));
assert!(r.vault.secrets.contains_key("DB_URL"));
assert_eq!(r.vault.secrets.len(), 3);
}
#[test]
fn merge_both_remove_same_key() {
let base = base_vault();
let mut ours = base.clone();
ours.secrets.remove("DB_URL");
let mut theirs = base.clone();
theirs.secrets.remove("DB_URL");
let r = merge_vaults(&base, &ours, &theirs);
assert!(r.conflicts.is_empty());
assert!(!r.vault.secrets.contains_key("DB_URL"));
}
#[test]
fn merge_ours_modifies_theirs_unchanged() {
let base = base_vault();
let mut ours = base.clone();
ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new-cipher-db".into();
let r = merge_vaults(&base, &ours, &base);
assert!(r.conflicts.is_empty());
assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new-cipher-db");
}
#[test]
fn merge_theirs_modifies_ours_unchanged() {
let base = base_vault();
let mut theirs = base.clone();
theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new-cipher-db".into();
let r = merge_vaults(&base, &base, &theirs);
assert!(r.conflicts.is_empty());
assert_eq!(r.vault.secrets["DB_URL"].shared, "theirs-new-cipher-db");
}
#[test]
fn merge_both_modify_same_secret() {
let base = base_vault();
let mut ours = base.clone();
ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-new".into();
let mut theirs = base.clone();
theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-new".into();
let r = merge_vaults(&base, &ours, &theirs);
assert_eq!(r.conflicts.len(), 1);
assert!(r.conflicts[0].field.contains("DB_URL"));
assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-new");
}
#[test]
fn merge_both_add_same_key() {
let base = base_vault();
let mut ours = base.clone();
ours.secrets.insert(
"NEW_KEY".into(),
SecretEntry {
shared: "ours-cipher".into(),
scoped: BTreeMap::new(),
},
);
let mut theirs = base.clone();
theirs.secrets.insert(
"NEW_KEY".into(),
SecretEntry {
shared: "theirs-cipher".into(),
scoped: BTreeMap::new(),
},
);
let r = merge_vaults(&base, &ours, &theirs);
assert_eq!(r.conflicts.len(), 1);
assert!(r.conflicts[0].field.contains("NEW_KEY"));
}
#[test]
fn merge_remove_vs_modify() {
let base = base_vault();
let mut ours = base.clone();
ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-modified".into();
let mut theirs = base.clone();
theirs.secrets.remove("DB_URL");
let r = merge_vaults(&base, &ours, &theirs);
assert_eq!(r.conflicts.len(), 1);
assert!(
r.conflicts[0]
.reason
.contains("modified on our side but removed on theirs")
);
}
#[test]
fn merge_recipient_added_one_side_conflicts() {
let base = base_vault();
let mut ours = base.clone();
ours.recipients.push("age1charlie".into());
let r = merge_vaults(&base, &ours, &base);
assert_eq!(r.conflicts.len(), 1);
assert!(r.conflicts[0].reason.contains("added on one side"));
assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
}
#[test]
fn merge_recipient_added_both_same() {
let base = base_vault();
let mut ours = base.clone();
ours.recipients.push("age1charlie".into());
let mut theirs = base.clone();
theirs.recipients.push("age1charlie".into());
let r = merge_vaults(&base, &ours, &theirs);
assert!(r.conflicts.is_empty());
assert_eq!(
r.vault
.recipients
.iter()
.filter(|r| *r == "age1charlie")
.count(),
1
);
}
#[test]
fn merge_recipient_removed_one_side_conflicts() {
let base = base_vault();
let mut ours = base.clone();
ours.recipients.retain(|r| r != "age1bob");
let r = merge_vaults(&base, &ours, &base);
assert!(!r.conflicts.is_empty());
assert!(r.vault.recipients.contains(&"age1bob".to_string()));
}
#[test]
fn merge_recipient_removed_both_sides_ok() {
let base = base_vault();
let mut ours = base.clone();
let mut theirs = base.clone();
ours.recipients.retain(|r| r != "age1bob");
theirs.recipients.retain(|r| r != "age1bob");
let r = merge_vaults(&base, &ours, &theirs);
assert!(r.conflicts.is_empty());
assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
}
#[test]
fn merge_schema_different_keys() {
let base = base_vault();
let mut ours = base.clone();
ours.schema.insert(
"API_KEY".into(),
SchemaEntry {
description: "api".into(),
example: None,
tags: vec![],
..Default::default()
},
);
let mut theirs = base.clone();
theirs.schema.insert(
"STRIPE".into(),
SchemaEntry {
description: "stripe".into(),
example: None,
tags: vec![],
..Default::default()
},
);
let r = merge_vaults(&base, &ours, &theirs);
assert!(r.conflicts.is_empty());
assert!(r.vault.schema.contains_key("API_KEY"));
assert!(r.vault.schema.contains_key("STRIPE"));
}
#[test]
fn merge_schema_same_key_conflict() {
let base = base_vault();
let mut ours = base.clone();
ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
let mut theirs = base.clone();
theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
let r = merge_vaults(&base, &ours, &theirs);
assert_eq!(r.conflicts.len(), 1);
assert!(r.conflicts[0].field.contains("schema.DB_URL"));
}
#[test]
fn merge_scoped_different_pubkeys() {
let base = base_vault();
let mut ours = base.clone();
ours.secrets
.get_mut("DB_URL")
.unwrap()
.scoped
.insert("age1alice".into(), "alice-scope".into());
let mut theirs = base.clone();
theirs
.secrets
.get_mut("DB_URL")
.unwrap()
.scoped
.insert("age1bob".into(), "bob-scope".into());
let r = merge_vaults(&base, &ours, &theirs);
assert!(r.conflicts.is_empty());
let entry = &r.vault.secrets["DB_URL"];
assert_eq!(entry.scoped["age1alice"], "alice-scope");
assert_eq!(entry.scoped["age1bob"], "bob-scope");
}
#[test]
fn merge_scoped_both_modify_same() {
let mut base = base_vault();
base.secrets
.get_mut("DB_URL")
.unwrap()
.scoped
.insert("age1alice".into(), "base-scope".into());
let mut ours = base.clone();
ours.secrets
.get_mut("DB_URL")
.unwrap()
.scoped
.insert("age1alice".into(), "ours-scope".into());
let mut theirs = base.clone();
theirs
.secrets
.get_mut("DB_URL")
.unwrap()
.scoped
.insert("age1alice".into(), "theirs-scope".into());
let r = merge_vaults(&base, &ours, &theirs);
assert_eq!(r.conflicts.len(), 1);
assert!(r.conflicts[0].field.contains("scoped"));
}
#[test]
fn merge_scoped_add_vs_base_key_removal() {
let base = base_vault();
let mut ours = base.clone();
ours.secrets.remove("DB_URL");
ours.schema.remove("DB_URL");
let mut theirs = base.clone();
theirs
.secrets
.get_mut("DB_URL")
.unwrap()
.scoped
.insert("age1alice".into(), "alice-scoped".into());
let r = merge_vaults(&base, &ours, &theirs);
assert!(!r.conflicts.is_empty());
assert!(r.vault.secrets.contains_key("DB_URL"));
}
#[test]
fn merge_scoped_add_vs_base_key_modification() {
let base = base_vault();
let mut ours = base.clone();
ours.secrets.remove("DB_URL");
ours.schema.remove("DB_URL");
let mut theirs = base.clone();
theirs.secrets.get_mut("DB_URL").unwrap().shared = "theirs-modified".into();
theirs
.secrets
.get_mut("DB_URL")
.unwrap()
.scoped
.insert("age1alice".into(), "alice-scoped".into());
let r = merge_vaults(&base, &ours, &theirs);
assert!(r.conflicts.len() >= 1);
assert!(r.conflicts.iter().any(|c| c.reason.contains("removed")));
}
#[test]
fn merge_ours_changes_recipients_theirs_adds_key() {
let base = base_vault();
let mut ours = base.clone();
ours.recipients.push("age1charlie".into());
ours.secrets.get_mut("DB_URL").unwrap().shared = "ours-reencrypted-db".into();
let mut theirs = base.clone();
theirs.secrets.insert(
"NEW_KEY".into(),
SecretEntry {
shared: "theirs-new".into(),
scoped: BTreeMap::new(),
},
);
let r = merge_vaults(&base, &ours, &theirs);
assert!(
r.conflicts
.iter()
.any(|c| c.reason.contains("added on one side"))
);
assert_eq!(r.vault.secrets["DB_URL"].shared, "ours-reencrypted-db");
assert!(r.vault.secrets.contains_key("NEW_KEY"));
assert!(r.vault.recipients.contains(&"age1charlie".to_string()));
}
#[test]
fn merge_takes_ours_meta() {
let base = base_vault();
let mut ours = base.clone();
ours.meta = "ours-meta".into();
let mut theirs = base.clone();
theirs.meta = "theirs-meta".into();
let r = merge_vaults(&base, &ours, &theirs);
assert_eq!(r.vault.meta, "ours-meta");
}
#[test]
fn run_merge_driver_invalid_base() {
let result = run_merge_driver("not json", "{}", "{}");
assert!(result.is_err());
assert!(result.unwrap_err().contains("parsing base"));
}
#[test]
fn run_merge_driver_invalid_ours() {
let base = serde_json::to_string(&base_vault()).unwrap();
let result = run_merge_driver(&base, "not json", &base);
assert!(result.is_err());
assert!(result.unwrap_err().contains("parsing ours"));
}
#[test]
fn run_merge_driver_invalid_theirs() {
let base = serde_json::to_string(&base_vault()).unwrap();
let result = run_merge_driver(&base, &base, "not json");
assert!(result.is_err());
assert!(result.unwrap_err().contains("parsing theirs"));
}
#[test]
fn run_merge_driver_clean_no_changes() {
let base = serde_json::to_string(&base_vault()).unwrap();
let output = run_merge_driver(&base, &base, &base).unwrap();
assert!(output.result.conflicts.is_empty());
}
#[test]
fn merge_preserves_ours_static_fields() {
let base = base_vault();
let mut ours = base.clone();
ours.vault_name = "custom.murk".into();
ours.repo = "https://github.com/test/repo".into();
let r = merge_vaults(&base, &ours, &base);
assert_eq!(r.vault.vault_name, "custom.murk");
assert_eq!(r.vault.repo, "https://github.com/test/repo");
assert_eq!(r.vault.version, VAULT_VERSION);
}
#[test]
fn merge_both_remove_same_recipient() {
let base = base_vault();
let mut ours = base.clone();
ours.recipients.retain(|r| r != "age1bob");
let mut theirs = base.clone();
theirs.recipients.retain(|r| r != "age1bob");
let r = merge_vaults(&base, &ours, &theirs);
assert!(!r.vault.recipients.contains(&"age1bob".to_string()));
assert!(
!r.conflicts.iter().any(|c| c.reason.contains("recipient")),
"removing same recipient from both sides should not conflict"
);
}
#[test]
fn merge_empty_vaults() {
let empty = Vault {
version: VAULT_VERSION.into(),
created: "2026-01-01T00:00:00Z".into(),
vault_name: ".murk".into(),
repo: String::new(),
recipients: vec!["age1alice".into()],
schema: BTreeMap::new(),
secrets: BTreeMap::new(),
meta: String::new(),
};
let r = merge_vaults(&empty, &empty, &empty);
assert!(r.conflicts.is_empty());
assert!(r.vault.secrets.is_empty());
}
#[test]
fn merge_schema_ours_changes_description() {
let base = base_vault();
let mut ours = base.clone();
ours.schema.get_mut("DB_URL").unwrap().description = "updated desc".into();
let r = merge_vaults(&base, &ours, &base);
assert_eq!(r.vault.schema["DB_URL"].description, "updated desc");
assert!(r.conflicts.is_empty());
}
#[test]
fn merge_schema_both_change_description_takes_ours() {
let base = base_vault();
let mut ours = base.clone();
ours.schema.get_mut("DB_URL").unwrap().description = "ours desc".into();
let mut theirs = base.clone();
theirs.schema.get_mut("DB_URL").unwrap().description = "theirs desc".into();
let r = merge_vaults(&base, &ours, &theirs);
assert_eq!(r.vault.schema["DB_URL"].description, "ours desc");
}
}