gitbutlerapp/gitbutler · error

worktree-changes are always set if there are hunks

Error message

worktree-changes are always set if there are hunks

What it means

In `but_core::tree` (tree/mod.rs), when committing selected hunks the code applies `hunks_to_commit` onto the worktree base and expects the corresponding worktree change data to exist — hence `unreachable!("worktree-changes are always set if there are hunks")`. The invariant is: a `ChangeRequest` carrying hunks must also carry the worktree changes those hunks were computed from. The panic fires when hunks are present but the worktree-change slot is `None`, i.e. an internally inconsistent change request.

Solutions

  1. Populate the worktree changes for every path that carries hunks before calling the tree-building API.
  2. Validate incoming requests: reject or skip paths where `hunks` is non-empty but worktree changes are `None`.
  3. After external worktree mutations, recompute hunks and worktree changes together from one diff instead of mixing snapshots.

Example fix

// before
if let Some(changes) = change_request.worktree_changes {
    // apply hunks onto base...
} else {
    unreachable!("worktree-changes are always set if there are hunks")
}

// after
let Some(changes) = change_request.worktree_changes else {
    anyhow::bail!(
        "path '{}' has hunks but no worktree changes; recompute the change request",
        change_request.path.display()
    );
};
Defensive patterns

Strategy: validation

Validate before calling

// reject inconsistent change requests before tree building
for req in &change_requests {
    if !req.hunks.is_empty() && req.worktree_changes.is_none() {
        anyhow::bail!(
            "path '{}' has hunks but no worktree changes; recompute the request",
            req.path.display()
        );
    }
}

Type guard

fn has_aligned_worktree_state(req: &ChangeRequest) -> bool {
    req.hunks.is_empty() || req.worktree_changes.is_some()
}

Prevention

When it happens

Trigger: Building a `ChangeRequest` with `hunks` populated but worktree changes left unset; reusing hunks computed against a worktree snapshot whose diff was later cleared; serialization round-trips (Tauri/SDK boundary) dropping the worktree-changes field while keeping hunks.

Common situations: Frontend sends partially-populated change requests; stale UI state after the worktree is reverted externally; DTO conversions that make worktree changes optional independently of hunks.

Related errors


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

Appendix: source

Thrown at crates/but-core/src/tree/mod.rs:349

                &md,
                base_rela_path,
                &path,
                &mut pipeline,
                &index,
            )?;
            let base_with_patches = apply_hunks(
                worktree_base.as_bstr(),
                current_worktree.as_bstr(),
                &hunks_to_commit,
            )?;
            let blob_with_selected_patches = repo.write_blob(base_with_patches.as_slice())?;
            base_tree_editor.upsert(
                change_request.path.as_bstr(),
                current_entry_kind,
                blob_with_selected_patches,
            )?;
        } else {
            unreachable!("worktree-changes are always set if there are hunks")
        }
    }

    let altered_base_tree_id = base_tree_editor.write()?;
    Ok((altered_base_tree_id, actual_base_tree))
}

/// Given `hunks_to_keep` (ascending hunks by starting line) and the set of `worktree_hunks_no_context`
/// (worktree hunks without context), return `(hunks_to_commit, rejected_hunks)`.
/// `hunks_to_commit` is the headers to drive the additive operation to create the buffer to commit, and `rejected_hunks` is the list of
/// hunks from `hunks_to_keep` that couldn't be associated with `worktree_hunks_no_context` because they weren't included.
///
/// `worktree_hunks` is the hunks with a given amount of context, usually 3, and it's used to quickly select original hunks
/// without sub-selection, which is needed when no sub-selections are specified for all hunks. Those with sub-selections and without
/// can be mixed freely though.
///
/// `hunks_to_keep` indicate that they are a selection of either old or new by marking the other side with `0,0`, i.e. `-1,2 +0,0` selects *old* `1,2`,
/// and `-0,0 +2,3` selects *new* `2,3`. Our job here is to rebuild the original hunk selections from that, as if the user had

View on GitHub (pinned to caf1f223d3)