Lazygit Custom Patch Building Architecture: How "Rebase Magic" Works Under the Hood
Lazygit's custom patch building architecture separates concerns into three tightly-coupled layers: a UI controller that manages the side-context view, a patch engine that tracks line selections in-memory, and a Git integration layer that executes git apply and continues the rebase.
Lazygit's "rebase magic" feature allows users to edit commits during an interactive rebase by building custom patches from specific diff lines. This functionality relies on a sophisticated architecture that balances UI responsiveness with Git command execution. Understanding this system reveals how the terminal application maintains clean separation between user interactions, in-memory state management, and Git orchestration.
The Three-Layer Architecture
The custom patch builder is organized into distinct layers that communicate through well-defined interfaces. Each layer has specific responsibilities and source files.
UI and Controller Layer
The UI layer handles keystrokes, manages the secondary panel view, validates working-tree state, and drives interface refreshes. The PatchBuildingHelper in pkg/gui/controllers/helpers/patch_building_helper.go serves as the glue between user input and the patch engine. This helper validates that the repository is not already in a rebase-conflict state via ValidateNormalWorkingTreeState before opening the builder.
The CustomPatchBuilder context declared in pkg/gui/types/context/custom_patch_builder.go sets up the secondary panel (side context) that displays the custom patch preview. When users toggle lines or confirm patches, this layer translates those actions into calls to the underlying engine.
Patch Engine Layer
The core logic resides in pkg/commands/patch/patch_builder.go, where the PatchBuilder struct maintains an in-memory representation of the patch. Rather than storing the entire patch text on every interaction, the engine stores the original diff once and tracks which line indices are included using a set.Set[int]. This makes toggling operations computationally cheap.
Supporting this are:
pkg/commands/patch/patch.go– Defines thePatchstruct with headers and hunks, providing helpers likeContainsChanges()pkg/commands/patch/format.go– ContainsformatViewfor colored display (green background for included lines) andformatPlainfor generating text suitable forgit apply
Git Integration Layer
This layer executes actual Git commands using the assembled patch. The Git.Patch wrapper in pkg/git/patch.go forwards operations to the PatchBuilder and runs git apply --cached when the user confirms. The rebase orchestration in pkg/commands/git_commands/rebase.go manages the interactive rebase flow and calls the patch engine when continuing after custom patch application.
How the Custom Patch Builder Works (Step-by-Step)
The architecture coordinates through a specific sequence when users engage "Edit commit" during an interactive rebase:
-
Entry and Validation – When the user selects "Edit commit",
PatchBuildingHelper.ValidateNormalWorkingTreeStateverifies the repo is not in a conflict state. If validation fails, the operation aborts with an error message. -
View Initialization – The helper opens the side context via
self.c.Context().Push(types.SIDE_CONTEXT)and callsRefreshPatchBuildingPanel. This retrieves the file diff viaGit.WorkingTree.ShowFileDiffand requests a rendered secondary diff fromPatchBuilder.RenderPatchForFile. -
Line Selection Tracking – As users toggle lines (via
ToggleLinein the controller), the engine adds or removes line indices from the inclusion set. TheformatViewfunction immediately reflects these changes with visual feedback—typically a green background on the first character of included lines. -
Patch Application – When the user presses Enter in the secondary panel,
Git.Patch.ApplyCustomPatchgenerates plain-text viaformatPlainand executesgit apply --cached. For reverse operations, it usesgit apply --reverse. -
State Reset and Continue – Upon successful application,
PatchBuilder.Reset()clears the inclusion set and underlying state. The UI returns to the commit-files panel, andgit rebase --continueproceeds automatically via the rebase controller. -
Abort Handling – Pressing Esc triggers
PatchBuildingHelper.Escape, popping the side context. Pressing Ctrl-D or invoking reset callsPatchBuildingHelper.Reset, which clears the builder state, refreshes the commit-files view, and hides the secondary panel.
Core Implementation Details
The interaction between these layers appears in several key code paths. When opening the builder, the helper coordinates between Git operations and the patch engine:
func (h *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpts) {
// Retrieve the diff of the selected file
diff, _ := h.c.Git().WorkingTree.ShowFileDiff(...)
// Render the current custom patch (may be empty)
secondaryDiff := h.c.Git().Patch.PatchBuilder.RenderPatchForFile(patch.RenderPatchForFileOpts{
Filename: path,
Plain: false,
Reverse: false,
TurnAddedFilesIntoDiffAgainstEmptyFile: true,
})
// Set up the side-context state
state := patch_exploring.NewState(diff, selectedLineIdx,
h.c.Contexts().CustomPatchBuilder.GetView(),
oldState, h.c.UserConfig().Gui.UseHunkModeInStagingView)
h.c.Contexts().CustomPatchBuilder.SetState(state)
// Render both panels
h.c.RenderToMainViews(types.RefreshMainOpts{
Pair: h.c.MainViewPairs().PatchBuilding,
Main: &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(state.GetMainContent()),
Title: h.c.Tr.Patch,
},
Secondary: &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(secondaryDiff),
Title: h.c.Tr.CustomPatch,
},
})
}
Toggling lines demonstrates the efficient index-tracking approach. The key-binding handler updates the engine state and refreshes the UI:
func (c *CustomPatchController) ToggleLine() error {
// Retrieve the current line index from the side view
lineIdx := c.c.Contexts().CustomPatchBuilder.GetState().CurrentLineIdx()
// Ask the engine to include/exclude the line
if err := c.c.Git().Patch.PatchBuilder.ToggleLine(lineIdx); err != nil {
return err
}
// Refresh UI so the line gets visual feedback
c.c.Helpers().PatchBuilding.RefreshPatchBuildingPanel(types.OnFocusOpts{})
return nil
}
Applying the patch and continuing the rebase shows the clean separation between the patch engine and Git commands:
func (c *RebaseController) ApplyCustomPatchAndContinue() error {
// Apply the accumulated custom patch (plain text)
if err := c.c.Git().Patch.ApplyCustomPatch(); err != nil {
return err
}
// Discard the builder so it won't interfere with later steps
c.c.Git().Patch.PatchBuilder.Reset()
// Continue the interactive rebase
return c.c.Commands().RebaseContinue()
}
The reset mechanism ensures abandoned patches never affect subsequent rebase steps:
func (h *PatchBuildingHelper) Reset() error {
h.c.Git().Patch.PatchBuilder.Reset()
if h.c.Context().CurrentStatic().GetKind() != types.SIDE_CONTEXT {
h.Escape()
}
h.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMIT_FILES}})
h.c.PostRefreshUpdate(h.c.Context().Current())
return nil
}
Summary
- Three-layer separation: The architecture cleanly divides UI control (
PatchBuildingHelper), in-memory patch modeling (PatchBuilder), and Git execution (Git.Patchwrapper) to maintain testability and modularity. - Efficient line tracking: Instead of storing patch text repeatedly, the engine in
pkg/commands/patch/patch_builder.gotracks only line indices in a set, making toggle operations instantaneous. - Side-context UI: The secondary panel lives in
types.SIDE_CONTEXT, managed throughpkg/gui/types/context/custom_patch_builder.go, providing split-view diff comparison. - Stateful reset:
PatchBuilder.Reset()guarantees that abandoned or applied patches clear their inclusion sets, preventing state leakage between rebase steps. - Integration testing: The flow is verified under
pkg/integration/tests/patch_building/, ensuring that building, applying, and resetting patches works correctly during live rebase operations.
Frequently Asked Questions
What files control the lazygit custom patch building UI?
The primary UI controller lives in pkg/gui/controllers/helpers/patch_building_helper.go, which manages opening, refreshing, escaping, and resetting the custom-patch view. The context definition resides in pkg/gui/types/context/custom_patch_builder.go, which configures the secondary panel used for the split-view display.
How does lazygit track which lines are included in a custom patch?
The PatchBuilder struct in pkg/commands/patch/patch_builder.go stores the original diff once and maintains a set.Set[int] of included line indices. When users toggle lines via ToggleLine, the engine adds or removes indices from this set. The format.go file then renders these selections with visual highlighting using formatView.
What happens when a user applies a custom patch during a rebase?
The ApplyCustomPatch method in the Git integration layer generates a plain-text patch via formatPlain and executes git apply --cached. Upon success, PatchBuilder.Reset() clears the builder state, and the rebase controller in pkg/commands/git_commands/rebase.go automatically runs git rebase --continue to proceed with the interactive rebase.
How does the patch builder reset its state?
State reset occurs through PatchBuilder.Reset() in the engine layer, which clears the inclusion set and underlying Patch data. The UI layer's PatchBuildingHelper.Reset() calls this method, pops the side context if necessary, and refreshes the commit-files view to ensure no visual remnants of the abandoned patch remain.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →