gitbutlerapp/gitbutler · error

existing GitMeta key '{record_hashes_key}' is not a set

Error message

existing GitMeta key '{record_hashes_key}' is not a set

What it means

The write path deduplicates incoming records against a `<session_prefix>:record-hashes` key that must be a `MetaValue::Set` of already-seen record hashes; `None` means a fresh set. If the key exists with any other variant, appending bails. This stops duplicate records but also stops all capture for that session until the key is fixed.

Source

Thrown at crates/but-agentlog/src/gitmeta/write.rs:96

        Some(MetaValue::List(entries)) => {
            stored_turn_summary_entries(entries.to_vec(), &turns_key)?
        }
        Some(_) => bail!("existing GitMeta key '{turns_key}' is not a list"),
    };
    let previous_turn_key = previous_turns
        .last()
        .map(|turn| turn.summary.turn_key.to_owned());
    let incoming_record_hashes = records
        .iter()
        .map(|record| record.source_record_hash.clone())
        .collect::<Vec<_>>();
    let mut seen_hashes = match handle
        .get_value(&record_hashes_key)
        .with_context(|| format!("failed to read GitMeta key '{record_hashes_key}'"))?
    {
        None => HashSet::new(),
        Some(MetaValue::Set(hashes)) => hashes.into_iter().collect(),
        Some(_) => bail!("existing GitMeta key '{record_hashes_key}' is not a set"),
    };
    records.retain(|record| {
        if seen_hashes.contains(&record.source_record_hash) {
            false
        } else {
            seen_hashes.insert(record.source_record_hash.clone());
            true
        }
    });

    if records.is_empty() {
        let metadata_changed = enrich_incomplete_turn(
            &handle,
            turns_value,
            session_key,
            source_key,
            &incoming_record_hashes,
            publication_status,

View on GitHub (pinned to caf1f223d3)

Solutions

  1. Run `but agentlog sync` and retry the operation
  2. Re-capture the session with the current `but` build to rewrite `:record-hashes` as a Set
  3. Align versions across machines and hooks
  4. Restore the last Set-valued version from GitMeta history, or delete the key (cost: records may be re-stored once until dedup state rebuilds)

Example fix

// before
let mut seen_hashes = match handle.get_value(&record_hashes_key)? {
    None => HashSet::new(),
    Some(MetaValue::Set(hashes)) => hashes.into_iter().collect(),
    Some(_) => bail!("existing GitMeta key '{record_hashes_key}' is not a set"),
};

// after: reset dedup state on a malformed key
let mut seen_hashes = match handle.get_value(&record_hashes_key)? {
    Some(MetaValue::Set(hashes)) => hashes.into_iter().collect(),
    _ => HashSet::new(),
};
Defensive patterns

Strategy: validation

Validate before calling

// Before publish, confirm the record-hashes key is a Set
if let Some(value) = handle.get_value(&record_hashes_key)? {
    if !matches!(value, MetaValue::Set(_)) { /* malformed; sync or delete key */ }
}

Type guard

fn dedup_state_is_set(v: Option<&MetaValue>) -> bool {
    matches!(v, Some(MetaValue::Set(_)))
}

Try / catch

match publish_records(&handle, &records) {
    Ok(()) => {}
    Err(err) if err.to_string().contains("record-hashes") => {
        eprintln!("dedup state malformed; run sync and retry — dedup rebuilds safely")
    }
    Err(err) => return Err(err),
}

Prevention

When it happens

Trigger: Hook capture or publish appending records to a session whose existing `:record-hashes` key holds a String or List — e.g. written by an incompatible writer version or reshaped by a metadata merge.

Common situations: Mixed `but` versions writing one session; a conflicted GitMeta sync resolved into the wrong shape; manual edits; restoring from an incompatible backup.

Related errors


AI-assisted analysis of gitbutlerapp/gitbutler@caf1f223d3 (2026-08-20). Data as JSON: /api/errors/e531a52e5b858495. Report an issue: GitHub.