GitButler Push Operation Edge Cases: Handling Remote Divergence, Force Protection, and Virtual Stacks

GitButler handles complex push scenarios—including diverged remotes, missing upstreams, and force-push protection—through a target-centric abstraction that resolves remote URLs dynamically and validates branch state before executing the underlying git push.

When working with the gitbutlerapp/gitbutler codebase, understanding how the push operation manages edge cases is critical for contributing to the virtual branch workflow. The GitButler push operation edge cases span from simple remote resolution to complex divergence detection and force-push safeguards. This guide examines the actual implementation in the Rust source code, showing exactly how the system handles misconfigured remotes, diverged histories, and virtual stack serialization.

What the GitButler Push Operation Does

When a user initiates a push, GitButler constructs a push request containing the target SHA, remote reference name (e.g., refs/heads/feature-xyz), and optional flags for force-push or CI-skip. This request flows through Context::push, which delegates to the low-level gitbutler_repo_actions::RepoActionsExt trait implementation.

The core entry point resides in crates/gitbutler-branch-actions/src/base.rs:

pub(crate) fn push(ctx: &Context, with_force: bool) -> Result<()> {
    let target = default_target(&ctx.project_data_dir())?;
    let _ = ctx.push(
        target.sha,
        &target.branch,
        with_force,
        ctx.legacy_project.force_push_protection,
        None,
        None,
        vec![],
    );
    Ok(())
}

This function retrieves the default target from VirtualBranchesHandle, then invokes the context's push method with the target's SHA and branch reference, respecting the project's force-push protection settings.

How GitButler Resolves Remote and Refname

Before executing the push, GitButler must resolve the remote URL and reference name through a multi-layer fallback system:

  1. Default target retrieval – Loaded from VirtualBranchesHandle (crates/gitbutler-stack/src/target.rs), containing the branch, SHA, and remote configuration.
  2. Push remote override – Users may set a specific push remote via set_target_push_remote, stored in target.push_remote_name.
  3. Fallback logic – If the push remote lacks a URL, the system falls back to the branch's original remote URL (target.remote_url).

The resolution logic in crates/gitbutler-branch-actions/src/base.rs handles this gracefully:

let push_remote_url = match target.push_remote_name {
    Some(ref name) => repo.find_remote(name).ok()
        .and_then(|r| r.url().map(|u| u.to_string()))
        .unwrap_or_else(|| target.remote_url.clone()),
    None => target.remote_url.clone(),
};

This ensures that even when the push remote is misconfigured or missing, the operation can still proceed using the upstream remote URL.

Critical Edge Cases in GitButler Push Operations

GitButler's push implementation handles several complex scenarios that traditional git workflows encounter. Here is how the source code addresses each GitButler push operation edge case:

No Push Remote Configured

When target.push_remote_name is None, the system defaults to the branch's upstream remote URL (target.remote_url). If both are empty, the low-level RepoActionsExt implementation returns an error that bubbles up to the UI. This detection occurs in push (base.rs) during the default_target call.

Remote Divergence Detection

GitButler detects when the local branch has diverged from the remote using graph_ahead_behind calculations in target_to_base_branch. The resulting BaseBranch struct populates diverged: true along with diverged_ahead and diverged_behind vectors containing the commit lists. This allows the UI to warn users before they attempt to push over diverged history.

Force Push Protection

The system respects ctx.legacy_project.force_push_protection, passed through from the high-level push function to Context::push. When enabled, RepoActionsExt aborts force pushes unless the user explicitly overrides the protection. This prevents accidental history rewriting on shared branches.

Pushing Virtual Stacks

When pushing a virtual stack (series of dependent branches), Stack::push_details builds the exact remote refname using reference.remote_reference(remote_name). This guarantees that the ref name matches the target's push remote, ensuring atomic pushes of complex branch structures without refname collisions.

Missing Remote Branch

If the upstream branch does not exist on the remote, BaseBranch::set_base_branch invokes maybe_find_branch_by_refname, which returns a clear error message: "remote branch '{}' not found". This prevents pushes to non-existent upstreams and guides users to set up tracking correctly.

Push After Rebasing

GitButler does not automatically recalculate the target SHA after a rebase operation. Callers must explicitly invoke set_base_branch to update the target SHA before pushing. This design guarantees that the push target reflects the new base commit, preventing accidental pushes of stale commit references.

How GitButler Determines What to Push

The Stack::push_details method in crates/gitbutler-stack/src/stack.rs extracts the precise commit SHA and remote refname for the specific series the user wants to push:

pub fn push_details(&self, ctx: &Context, branch_name: String) -> Result<PushDetails> {
    self.ensure_initialized()?;
    let (_, reference) = get_head(&self.heads, &branch_name)?;
    let oid = reference.head_oid(&*ctx.repo.get()?)?.to_git2();
    let git2_repo = ctx.git2_repo.get()?;
    let commit = git2_repo.find_commit(oid)?;
    let remote_name = branch_state(ctx).get_default_target()?.push_remote_name();
    let upstream_refname = RemoteRefname::from_str(&reference.remote_reference(remote_name.as_str()))?;
    Ok(PushDetails { head: commit.id(), remote_refname: upstream_refname })
}

This method ensures that:

  • The commit SHA corresponds to the exact head of the selected virtual branch or series.
  • The remote reference is constructed from the branch's stored upstream combined with the active push remote name.

Practical Usage Patterns

Push the Current Base Branch (No Force)

use gitbutler_branch_actions;

// Context is provided by the application runtime
gitbutler_branch_actions::push_base_branch(&ctx, false)?;

This invokes push in base.rs, delegating to Context::push with with_force set to false.

Force Push with Protection Override

gitbutler_branch_actions::push_base_branch(&ctx, true)?;

The force_push_protection setting from the project configuration is automatically consulted. If enabled, the operation aborts unless the user explicitly confirms.

Push a Specific Virtual Series

use gitbutler_stack::Stack;

let stack = Stack::load(&ctx, "feature-xyz")?;
let details = stack.push_details(&ctx, "feature-xyz".into())?;
ctx.push(
    details.head,
    &details.remote_refname,
    false,  // no force
    false,  // no protection override
    None, None, vec![],
)?;

The push_details call guarantees the correct remote refname for the virtual stack's series.

Change the Push Remote Target

gitbutler_branch_actions::set_target_push_remote(&ctx, "origin")?;

This updates Target.push_remote_name in the project state, affecting subsequent push operations.

Key Source Files for Push Operations

File Responsibility Important Symbols
crates/gitbutler-branch-actions/src/base.rs High-level push orchestration, push-remote handling, base-branch data push, set_target_push_remote, target_to_base_branch
crates/gitbutler-stack/src/stack.rs Determines what to push (SHA + remote refname) push_details
crates/gitbutler-stack/src/target.rs Stores the default push target (Target) Target { branch, remote_url, sha, push_remote_name }
crates/gitbutler-branch-actions/src/actions.rs Public API exposed to UI / Tauri push_base_branch, set_base_branch
crates/gitbutler-repo-actions/src/lib.rs Low-level RepoActionsExt::push implementation (executes the actual git push) RepoActionsExt
crates/gitbutler-stack/tests/mod.rs Test suite exercising push edge cases (e.g., diverged, force) push_series_success, update_name_after_push

Summary

  • GitButler push operation edge cases are handled through a target-centric model that dynamically resolves remote URLs and reference names.
  • The system detects remote divergence via graph_ahead_behind calculations, surfacing diverged_ahead and diverged_behind vectors to the UI.
  • Force-push protection is enforced by default through ctx.legacy_project.force_push_protection, requiring explicit user confirmation to override.
  • Virtual stacks use Stack::push_details to guarantee atomic pushes with correct remote refnames derived from the active push remote.
  • Callers must explicitly invoke set_base_branch after rebasing to ensure the push target SHA reflects the new base commit.

Frequently Asked Questions

How does GitButler handle pushing when no push remote is configured?

When target.push_remote_name is None, GitButler falls back to the branch's upstream remote URL (target.remote_url). If both values are empty, the low-level RepoActionsExt implementation returns an error that propagates to the UI, preventing pushes to undefined remotes.

What happens when the local branch diverges from the remote?

GitButler detects divergence through target_to_base_branch, which invokes graph_ahead_behind to compute commit lists. The resulting BaseBranch struct sets diverged: true and populates diverged_ahead and diverged_behind vectors, enabling the UI to warn users before they force-push over remote commits.

How does force-push protection work in GitButler?

The push function in base.rs passes ctx.legacy_project.force_push_protection to the low-level Context::push method. When enabled, RepoActionsExt aborts force pushes unless the user explicitly overrides the protection, preventing accidental history rewriting on shared branches.

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 →