How GitButler Implements Branch Absorption: A Deep Dive into the Source Code

GitButler's branch absorption feature redistributes uncommitted worktree changes into existing commits through a three-phase pipeline that analyzes hunk assignments, resolves target commits via dependency locks and stack assignments, and amends commits using the legacy absorb API in crates/but-api/src/legacy/absorb.rs.

GitButler branch absorption allows developers to automatically integrate uncommitted work into their existing commit history without manual rebasing. This feature, implemented in the gitbutlerapp/gitbutler repository, processes worktree hunks, determines optimal target commits based on complex dependency rules, and executes an absorption plan that amends commits accordingly. Understanding the internal implementation reveals how GitButler manages automated commit redistribution while handling stack dependencies and conflict resolution.

The Three Phases of GitButler Branch Absorption

When a user executes but absorb <branch-id> or calls the equivalent API, GitButler branch absorption executes through three distinct phases:

  1. Collect assignments for the target branch – Locate the stack containing the branch and filter worktree hunk assignments to that specific stack.
  2. Build an absorption plan – Group assignments by target commit, resolve the exact commit for each hunk using dependency locks or stack assignments, and create CommitAbsorption objects.
  3. Execute the plan – Generate diffs from assignments and amend each target commit (or create blank commits when necessary).

All core logic resides in crates/but-api/src/legacy/absorb.rs, with public entry points at but_api::legacy::absorb::absorption_plan and but_api::legacy::absorb::absorb.

Collecting Assignments for the Target Branch

The first phase filters the global worktree state to isolate changes relevant to the specified branch. In crates/but-api/src/legacy/absorb.rs, lines 78-101 handle the branch-specific path inside absorption_plan:

AbsorptionTarget::Branch { branch_name } => {
    // 1️⃣ Get all work‑tree changes + assignments
    let worktree_changes = changes_in_worktree(ctx)?;
    let all_assignments = worktree_changes.assignments;

    // 2️⃣ Resolve the stack that owns the named branch
    let stacks = crate::legacy::workspace::stacks(ctx, None)?;
    let stack = stacks
        .iter()
        .find(|s| {
            s.heads.iter().any(|h| {
                h.name.to_str().map(|n| n == branch_name).unwrap_or(false)
            })
        })
        .ok_or_else(|| anyhow::anyhow!("Branch not found: {}", branch_name))?;

    // 3️⃣ Keep only assignments that belong to that stack
    let stack_id = stack.id.ok_or_else(|| anyhow::anyhow!("Stack has no ID"))?;
    let stack_assignments: Vec<_> = all_assignments
        .iter()
        .filter(|a| a.stack_id == Some(stack_id))
        .cloned()
        .collect();

    if stack_assignments.is_empty() {
        anyhow::bail!("No uncommitted changes assigned to branch: {}", branch_name);
    }
    stack_assignments
}

The changes_in_worktree function gathers every uncommitted hunk and its current assignment, while crate::legacy::workspace::stacks enumerates all stacks and their heads. The filter a.stack_id == Some(stack_id) isolates only the hunk assignments that belong to the branch's stack.

Building the Absorption Plan

Once assignments are collected, GitButler branch absorption constructs a detailed execution plan through three sub-phases: grouping by target commit, resolving target commit priorities, and preparing final absorption objects.

Grouping Changes by Target Commit

At line 49 in absorption_plan, the filtered assignments pass to group_changes_by_target_commit:

let changes_by_commit = group_changes_by_target_commit(
    ctx,
    &assignments,
    guard.write_permission(),
)?;

The implementation (lines 58-83) iterates through assignments and organizes them into a BTreeMap keyed by (stack_id, commit_id):

fn group_changes_by_target_commit(
    ctx: &mut Context,
    assignments: &[HunkAssignment],
    perm: &mut RepoExclusive,
) -> anyhow::Result<GroupedChanges> {
    let mut changes_by_commit: GroupedChanges = BTreeMap::new();
    let mut stack_details_cache = HashMap::<StackId, StackDetails>::new();

    for assignment in assignments {
        // 2️⃣ Determine the exact commit this hunk should go into
        let (stack_id, commit_id, reason) =
            determine_target_commit(ctx, assignment, &mut stack_details_cache, perm)?;

        // 3️⃣ Insert into the map keyed by (stack, commit)
        let entry = changes_by_commit
            .entry((stack_id, commit_id))
            .or_insert_with(|| (Vec::new(), reason.clone()));

        entry.0.push(assignment.clone());
        // Hunk‑dependency takes precedence as the reason
        if reason == AbsorptionReason::HunkDependency {
            entry.1 = reason;
        }
    }
    Ok(changes_by_commit)
}

Determining the Target Commit

The determine_target_commit function (lines 22-117) implements a priority-based resolution system for GitButler branch absorption:

fn determine_target_commit(
    ctx: &mut Context,
    assignment: &HunkAssignment,
    stack_details_cache: &mut HashMap<StackId, StackDetails>,
    perm: &mut RepoExclusive,
) -> anyhow::Result<(but_core::ref_metadata::StackId, gix::ObjectId, AbsorptionReason)> {
    // ── 1️⃣ Lock‑based dependency → highest lock wins
    if let Some(locks) = &assignment.hunk_locks {
        if let Some(lock) = find_top_most_lock(locks, ctx, stack_details_cache) {
            if let HunkLockTarget::Stack(stack_id) = lock.target {
                return Ok((stack_id, lock.commit_id, AbsorptionReason::HunkDependency));
            }
        } else {
            anyhow::bail!("Failed to determine target commit …");
        }
    }

    // ── 2️⃣ Stack‑assignment → topmost commit of that stack
    if let Some(stack_id) = assignment.stack_id {
        let stack_details = crate::legacy::workspace::stack_details(ctx, Some(stack_id))?;
        if let Some(branch) = stack_details.branch_details.first()
            && let Some(commit) = branch.commits.first()
        {
            return Ok((stack_id, commit.id, AbsorptionReason::StackAssignment));
        }

        // If the stack is empty, create a blank commit first …
        // (blank‑commit creation code omitted for brevity)
    }

    // ── 3️⃣ Default fallback → left‑most stack’s topmost commit
    let stacks = crate::legacy::workspace::stacks(ctx, None)?;
    if let Some(stack) = stacks.first()
        && let Some(stack_id) = stack.id
    {
        // same “first‑commit” logic as above …
    }

    anyhow::bail!("Unable to determine target commit for unassigned change: {}", assignment.path);
}

The resolution follows strict priority rules:

Priority Source Reason
1 assignment.hunk_locks AbsorptionReason::HunkDependency – a lock from another stack forces the hunk into that commit.
2 assignment.stack_id AbsorptionReason::StackAssignment – the hunk is already assigned to a stack; the topmost commit of that stack is used (blank commit created if needed).
3 No explicit assignment AbsorptionReason::DefaultStack – the left‑most stack in the workspace is chosen.

Preparing Commit Absorptions

The final planning stage converts grouped changes into executable CommitAbsorption objects. The prepare_commit_absorptions function iterates over stacks in application order (parent → child) and builds absorption targets:

for stack_id in all_stack_ids {
    if let Some(stack_details) = stack_details_map.get(&stack_id) {
        for branch in stack_details.branch_details.iter().rev() {
            for commit in branch.commits.iter().rev() {
                let key = (stack_id, commit.id);
                if let Some((assignments, reason)) = changes_by_commit.get(&key) {
                    // Build FileAbsorption list …
                    commit_absorptions.push(CommitAbsorption {
                        stack_id,
                        commit_id: commit.id,
                        commit_summary: get_commit_summary(&*ctx.repo.get()?, commit.id)?,
                        files,
                        reason: reason.clone(),
                    });
                }
            }
        }
    }
}

This produces the final plan that the UI displays in the absorption modal and that the execution engine consumes.

Executing the Absorption Plan

The execution phase transforms the planned absorptions into actual git operations. The public absorb function in crates/but-api/src/legacy/absorb.rs creates a snapshot for undo support before delegating to the implementation:

pub fn absorb(ctx: &mut Context, absorption_plan: Vec<CommitAbsorption>) -> anyhow::Result<usize> {
    let mut guard = ctx.exclusive_worktree_access();
    let repo = ctx.repo.get()?;
    let data_dir = ctx.project_data_dir();

    // Snapshot for undo support
    ctx.create_snapshot(
        SnapshotDetails::new(OperationKind::Absorb),
        guard.write_permission(),
    ).ok();

    absorb_impl(absorption_plan, &mut guard, &repo, &data_dir)
}

The absorb_impl function (lines 46-73) performs the actual git operations:

pub fn absorb_impl(
    absorption_plan: Vec<CommitAbsorption>,
    guard: &mut RepoExclusiveGuard,
    repo: &gix::Repository,
    data_dir: &Path,
) -> anyhow::Result<usize> {
    let mut total_rejected = 0;
    let mut commit_map = CommitMap::default();

    for absorption in absorption_plan {
        // 1️⃣ Turn assignments → diff specs
        let diff_specs = convert_assignments_to_diff_specs(
            &absorption.files.iter().map(|f| f.assignment.clone()).collect::<Vec<_>>(),
        )?;

        // 2️⃣ Amend the target commit (or create a blank one) and collect failures
        let outcome = amend_commit_and_count_failures(
            absorption.stack_id,
            commit_map.find_mapped_id(absorption.commit_id),
            diff_specs,
            guard,
            repo,
            data_dir,
        )?;

        // 3️⃣ Record new commit‑id mappings (rebases may change IDs)
        for mapping in &outcome.commit_mapping {
            commit_map.add_mapping(mapping.0, mapping.1);
        }

        total_rejected += outcome.paths_to_rejected_changes.len();
    }
    Ok(total_rejected)
}

This function converts assignments to diff specifications, amends target commits using but_rebase internals, and tracks commit ID mappings to handle rebasing side effects. It returns the count of rejected changes, enabling the CLI to surface warnings for hunks that could not be absorbed due to conflicts.

CLI and API Integration

GitButler exposes branch absorption through both command-line and HTTP interfaces. The CLI implementation in crates/but/src/command/legacy/absorb.rs orchestrates the workflow:

let absorption_plan = but_api::legacy::absorb::absorption_plan(ctx, target)?;
let plan_json = display_absorption_plan(&absorption_plan, out, new, dry_run)?;
if !dry_run {
    but_api::legacy::absorb::absorb(ctx, absorption_plan)?;
}

This code builds the absorption plan, optionally displays it as JSON for inspection, and executes the plan unless running in dry-run mode.

The HTTP API exposes the same functionality through the server route defined in crates/but-server/src/lib.rs:

.route("/absorb", post(json_response(legacy::absorb::absorb_cmd)))

This endpoint forwards JSON requests to the same legacy implementation, enabling UI-driven branch absorption workflows.

Usage Examples

GitButler branch absorption supports several operational modes through the but absorb command:


# Absorb all changes assigned to the branch "feature/login"

but absorb feature/login

# Preview the absorption plan without modifying history

but absorb feature/login --dry-run --json

# Create new commits instead of amending existing ones

but absorb feature/login --new

The --dry-run option triggers the planning phase through absorption_plan and outputs JSON showing which files will be absorbed into which commits, allowing users to verify the operation before execution.

Summary

GitButler branch absorption automates the redistribution of uncommitted work into existing commit history through a sophisticated three-phase pipeline:

  • Assignment Collection: Filters worktree hunks to those assigned to the target branch's stack by resolving stack IDs and matching hunk assignments in crates/but-api/src/legacy/absorb.rs.
  • Plan Construction: Groups changes by target commit using group_changes_by_target_commit, resolves commit targets via priority rules (hunk locks > stack assignment > default stack) in determine_target_commit, and builds executable CommitAbsorption objects.
  • Plan Execution: Amends commits using absorb_impl and amend_commit_and_count_failures, tracks commit ID mappings through rebasing operations, and returns rejection counts for conflict handling.

The feature exposes consistent APIs through both the but absorb CLI command and the /absorb HTTP endpoint, supporting dry-run previews and flexible commit creation strategies.

Frequently Asked Questions

How does GitButler determine which commit receives an absorbed hunk?

GitButler branch absorption resolves target commits through a three-tier priority system implemented in determine_target_commit. First, it checks for hunk locks (AbsorptionReason::HunkDependency) that force the hunk into a specific commit on another stack. If no locks exist, it uses stack assignment (AbsorptionReason::StackAssignment) to target the topmost commit of the assigned stack. Finally, unassigned hunks fall back to the default stack (AbsorptionReason::DefaultStack), which selects the leftmost stack's topmost commit.

Can I preview changes before running branch absorption?

Yes, GitButler branch absorption supports dry-run mode via the --dry-run flag. When invoked as but absorb <branch> --dry-run --json, the CLI calls absorption_plan to construct the full execution plan without modifying the repository. It outputs a JSON representation showing which files will be absorbed into which commits, including the CommitAbsorption objects with their stack IDs, commit summaries, and absorption reasons. This allows you to verify the target commit resolution and file mappings before executing the actual absorption.

What happens if a hunk cannot be absorbed into its target commit?

During execution in absorb_impl, GitButler branch absorption handles failures through the amend_commit_and_count_failures function. If a hunk conflicts with its target commit or cannot be applied for other reasons, the function records the rejection in outcome.paths_to_rejected_changes rather than failing the entire operation. The absorb_impl function aggregates these rejections across all commits in the plan and returns the total count of rejected changes. This allows the CLI to surface specific warnings about which paths could not be absorbed while successfully processing all applicable hunks.

How does GitButler track commit identity during absorption?

GitButler branch absorption maintains commit identity through the CommitMap structure during absorb_impl. When amend_commit_and_count_failures amends a commit or creates a new one, Git operations may generate new commit IDs due to rebasing or tree modifications. The function returns outcome.commit_mapping, which contains pairs of original and new commit IDs. The code iterates through these mappings and calls commit_map.add_mapping to record the identity transformation. Subsequent absorptions in the same plan use commit_map.find_mapped_id to ensure they target the correct, updated commit IDs rather than stale references.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →