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

> Explore how GitButler implements branch absorption by analyzing worktree changes, resolving commits, and amending with the legacy absorb API. Dive into the source code.

- Repository: [GitButler/gitbutler](https://github.com/gitbutlerapp/gitbutler)
- Tags: deep-dive
- Published: 2026-02-16

---

**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`](https://github.com/gitbutlerapp/gitbutler/blob/main/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`](https://github.com/gitbutlerapp/gitbutler/blob/main/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`](https://github.com/gitbutlerapp/gitbutler/blob/main/crates/but-api/src/legacy/absorb.rs), lines 78-101 handle the branch-specific path inside `absorption_plan`:

```rust
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`:

```rust
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)`:

```rust
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:

```rust
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:

```rust
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`](https://github.com/gitbutlerapp/gitbutler/blob/main/crates/but-api/src/legacy/absorb.rs) creates a snapshot for undo support before delegating to the implementation:

```rust
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:

```rust
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`](https://github.com/gitbutlerapp/gitbutler/blob/main/crates/but/src/command/legacy/absorb.rs) orchestrates the workflow:

```rust
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`](https://github.com/gitbutlerapp/gitbutler/blob/main/crates/but-server/src/lib.rs):

```rust
.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:

```bash

# 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`](https://github.com/gitbutlerapp/gitbutler/blob/main/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.