How Lazygit's Staging View Handles Partial File Staging and Hunk Management

Lazygit's staging view enables partial file staging by converting UI selections into targeted Git patches through a patch_exploring.State system that maps view lines to patch indices, then applies them via git apply --cached.

The jesseduffield/lazygit terminal UI provides granular control over Git staging through its sophisticated staging view, allowing users to stage individual lines, ranges, or entire hunks without leaving the interface. This partial file staging capability relies on a patch-exploration subsystem that translates visual selections into precise Git operations.

Architecture of the Staging View

The staging view orchestrates four core components to manage partial staging. The StagingController (pkg/gui/controllers/staging_controller.go) handles keybindings and orchestrates patch application. The StagingHelper (pkg/gui/controllers/helpers/staging_helper.go) refreshes the dual panels and builds diff strings. The patch_exploring.State (pkg/gui/patch_exploring/state.go) tracks cursor position and selection modes while mapping between view lines and patch lines. Finally, the internal patch.Patch library (pkg/commands/patch) parses unified diffs and generates subset patches.

The workflow follows a linear pipeline: user selection → state mapping → patch transformation → Git application. When you select lines in the UI, the State object converts those view indices into patch line numbers. The StagingController then generates a temporary patch containing only those lines and applies it to the index or working tree.

Selection Modes and Hunk Management

Lazygit supports three distinct selection modes controlled via State.selectMode: LINE, RANGE, and HUNK. In LINE mode, the cursor targets a single line. RANGE mode activates when you hold Shift or toggle range selection, creating a non-sticky selection across multiple lines. HUNK mode selects the entire change block containing the cursor.

You toggle hunk mode using ToggleSelectHunk, implemented in pkg/gui/patch_exploring/state.go:

func (s *State) ToggleSelectHunk() {
    if s.selectMode == HUNK {
        s.selectMode = LINE
    } else {
        s.selectMode = HUNK
        s.userEnabledHunkMode = true
        s.selectedLineIdx = s.viewLineIndices[s.patch.GetNextChangeIdx(s.patchLineIndices[s.selectedLineIdx])]
    }
}

When hunk mode activates, the cursor automatically jumps to the next change line within the current hunk. Lazygit can also auto-enable hunk mode by default if configured:

if useHunkModeByDefault && !patch.IsSingleHunkForWholeFile() {
    selectMode = HUNK
}

Mapping UI Selections to Patch Lines

The State maintains two parallel index slices to bridge the gap between wrapped view lines and actual patch content:

viewLineIndices []int   // view line -> patch line
patchLineIndices []int  // patch line -> view line

When you select a range, the controller retrieves the corresponding patch line indices through SelectedPatchRange:

func (s *State) SelectedPatchRange() (int, int) {
    start, end := s.SelectedViewRange()
    return s.patchLineIndices[start], s.patchLineIndices[end]
}

This bidirectional mapping ensures that wrapped display lines correctly resolve to the underlying diff lines, regardless of terminal width or line wrapping.

Building and Applying Partial Patches

The core staging logic resides in StagingController.applySelection, which constructs a temporary Git patch from your selection. The process uses the patch library's transform capabilities:

firstLineIdx, lastLineIdx := state.SelectedPatchRange()
patchToApply := patch.
    Parse(state.GetDiff()).
    Transform(patch.TransformOpts{
        Reverse:             reverse,
        IncludedLineIndices: patch.ExpandRange(firstLineIdx, lastLineIdx),
        FileNameOverride:    path,
    }).
    FormatPlain()

The ExpandRange function ensures that wrapped view lines mapping to the same patch line are fully included, preventing malformed patches. For hunk selections, firstLineIdx and lastLineIdx span the complete hunk boundaries.

Once generated, the controller applies the patch through the Git command layer:

err := self.c.Git().Patch.ApplyPatch(
    patchToApply,
    git_commands.ApplyPatchOpts{
        Reverse: reverse,
        Cached:  !reverse || self.staged,
    },
)

Setting Cached: true stages changes to the index during normal staging operations. When discarding (reverse: true), the patch applies in reverse without the --cached flag, removing changes from the working tree.

Refreshing the Staging Panels

After every operation, StagingHelper.RefreshStagingPanel reconciles the dual-panel view by fetching fresh diffs for both staged and unstaged content:

mainDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, false)   // unstaged
secondaryDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, true) // staged

These diffs feed into patch_exploring.NewState, which preserves cursor position when possible while updating the available content and hunk boundaries.

User Workflow and Keybindings

Action Default Key Implementation
Stage/Unstage Space Calls applySelectionAndRefresh with reverse: false, building a partial patch and running git apply --cached
Toggle Hunk Mode Ctrl-h Invokes ToggleSelectHunk, switching between LINE and HUNK selection modes
Range Select Shift-↑/↓ or V Activates RANGE mode via ToggleSelectRange for multi-line selection
Discard Selection d Calls applySelectionAndRefresh(true) to apply the patch in reverse
Edit Hunk e Opens the hunk in an external editor, then reapplies the modified patch

Summary

  • Three selection modes (LINE, RANGE, HUNK) in patch_exploring.State control the granularity of partial staging operations.
  • Bidirectional index mapping via viewLineIndices and patchLineIndices ensures accurate translation between wrapped UI lines and actual diff lines.
  • Patch transformation through patch.Parse and Transform generates minimal patches containing only selected lines using ExpandRange for boundary safety.
  • Git application via ApplyPatch with Cached: true stages content, while reverse application handles discards.
  • Dual-panel refresh through StagingHelper maintains synchronized views of staged and unstaged changes using WorktreeFileDiff.

Frequently Asked Questions

How does lazygit map visual lines to actual patch lines?

Lazygit maintains two parallel slices in patch_exploring.State: viewLineIndices maps view lines to patch lines, while patchLineIndices maps patch lines back to view lines. When you select a range, SelectedPatchRange() uses these slices to convert view indices into the patch line numbers required for generating the partial patch.

What is the difference between LINE, RANGE, and HUNK selection modes?

LINE mode stages the single line under your cursor. RANGE mode allows you to select multiple consecutive lines using Shift or visual mode. HUNK mode selects the entire diff hunk containing your cursor. You toggle hunk mode with Ctrl-h, which calls ToggleSelectHunk() and automatically repositions the cursor to the next change within the hunk.

How does lazygit stage only part of a file to the git index?

The StagingController calls applySelection, which retrieves the selected patch line range via State.SelectedPatchRange(). It then parses the full diff using patch.Parse(), transforms it with IncludedLineIndices set to your selection, and formats a new patch. Finally, it executes git apply --cached via ApplyPatch() with Cached: true to add only those lines to the index.

Can you discard individual lines or hunks using the staging view?

Yes. When you press d to discard, the controller calls applySelectionAndRefresh with reverse: true. This generates a patch from your selection and applies it in reverse using git apply without the --cached flag, effectively removing those specific changes from the working tree while leaving the rest of the file intact.

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 →