GitButler Uncommitted File Tracking Mechanism: Real-Time Redux Streaming Architecture
GitButler tracks uncommitted files by streaming worktree changes from a backend service into a normalized Redux store that maintains tree changes, hunk assignments, and user selections while preserving partial line selections across updates.
The GitButler uncommitted file tracking mechanism provides developers with a live, interactive view of their working directory changes. Implemented in the gitbutlerapp/gitbutler repository, this system combines a Rust-powered backend event stream with a sophisticated frontend architecture using RTK-Query and Redux entity adapters. The result is a responsive interface that displays file diffs, tracks hunk assignments, and maintains user selections even as the underlying worktree changes in real time.
Backend Source of Truth with WorktreeService
The tracking system begins with the WorktreeService in apps/desktop/src/lib/worktree/worktreeService.svelte.ts, which creates an RTK-Query endpoint named worktreeChanges. This endpoint opens a subscription to the backend event stream project://<id>/worktree_changes【L86-L112】.
When the backend pushes a WorktreeChanges payload, the service transforms the raw data into normalized entity adapters:
changes→ stored inworktreeAdapter(entities of typeTreeChange) containing one entry per file path for raw diff lookupsassignments→ stored inhunkAssignmentAdapter(entities of typeHunkAssignment) describing each hunk's line ranges that can be selecteddependenciesanderrors→ stored unchanged for error UI rendering
The transformed result is cached and automatically invalidates the Diff tag, ensuring any UI component reading the diff list refreshes automatically【L119-L132】.
Normalizing Data in the Client Redux Slice
The normalized data flows into the uncommitted Redux slice defined in apps/desktop/src/lib/selection/uncommitted.ts【L41-L48】. This slice maintains three distinct entity collections using Redux Toolkit's entity adapters:
| Collection | Adapter | Purpose |
|---|---|---|
treeChanges |
treeChangeAdapter |
Raw diff for each file (path → TreeChange) |
hunkAssignments |
hunkAssignmentAdapter |
Line ranges for every file/hunk that can be selected (lineNumsAdded/Removed) |
hunkSelection |
hunkSelectionAdapter |
User's current selection state; empty lines array indicates the whole hunk is selected |
Preserving Selections Across Backend Updates
When the backend pushes a new payload, the slice's update reducer (uncommittedSlice.reducers.update) calls the helper updateAssignments【L59-L62】. This function performs a three-way merge:
- Replaces
treeChangesandhunkAssignmentsentirely with the new backend data - Walks the previous
hunkSelectionentries, matching them to new assignments by stable ID or composite key, and preserves line selections where possible using theupdateLineshelper - Removes any selections that no longer have a matching assignment
This ensures the UI never loses a user's partial line selections between worktree refreshes, even as files change on disk.
UI-Level State and Selectors
Selectors under uncommittedSelectors expose the slice in a UI-friendly format:
treeChanges.selectByPath– fetches a file’s diff by pathhunkSelection.fileCheckStatus,folderCheckStatus,stackCheckStatus– compute the tri-state (checked,indeterminate,unchecked) for files, folders, or entire stacks based on underlying hunk selections【L99-L124】hunkCheckStatus– returns whether a specific hunk is selected and which lines are selected
These selectors drive the Uncommitted Changes panel (referenced by test-IDs uncommitted-changes-file-list and file-list-item in packages/ui/src/lib/utils/testIds.ts【L40-L42】) and enable bulk actions such as "check all files in a stack."
Actions That Mutate the Selection
The slice exports uncommittedActions including checkFile, uncheckFile, checkHunk, uncheckLine, checkStack, and others【L200-L559】. When a user clicks a checkbox, the corresponding action updates hunkSelection via entity adapters. The reducers contain logic to:
- Add a whole-hunk selection (
checkHunk→lines: []) - Add a single line (
checkLine→ appends the line, merges to full-hunk when all lines become selected) - Remove selections and collapse empty line arrays back to "no selection"
End-to-End Data Flow
The complete GitButler uncommitted file tracking mechanism operates as follows:
- App initialization →
WorktreeServicecreates theworktreeChangesquery subscription - Backend emits a
worktree_changesevent → RTK-Query cache updates with normalized entities - Redux update → The
uncommittedslice receives the payload via itsupdatereducer, preserving existing UI selections - UI rendering → Components read the slice through selectors, render the file list, and compute checkbox tri-state
- User interaction → Dispatches
uncommittedActions; reducers mutatehunkSelection - Commit preparation → Selected hunks are extracted from
hunkSelectionand sent to the backend for committing
Code Examples
Subscribing to the Worktree Changes Stream
import { WORKTREE_SERVICE } from '$lib/worktree/worktreeService.svelte';
import type { ClientState } from '$lib/state/clientState.svelte';
function initWorktree(state: ClientState) {
const service = new WorktreeService(state);
// Subscribe to the RTK-Query endpoint – data stays live while mounted
const { data, isLoading } = service.worktreeData('my-project-id');
$: if (data) {
console.log('Uncommitted files:', data.rawChanges);
}
}
The subscription is established in the worktreeChanges endpoint (lines 86-112 of worktreeService.svelte.ts).
Dispatching File Selection Actions
import { uncommittedActions } from '$lib/selection/uncommitted';
import { store } from '$lib/state/store';
// When the user clicks the file-level checkbox:
function onFileCheck(stackId: string | null, path: string) {
store.dispatch(uncommittedActions.checkFile({ stackId, path }));
}
The reducer adds a selection entry for every hunk belonging to the file (lines = [] indicates the whole hunk is selected) (lines 200-218).
Reading File Check Status
import { uncommittedSelectors } from '$lib/selection/uncommitted';
import type { RootState } from '$lib/state/store';
function fileStatus(state: RootState, stackId: string | null, path: string) {
return uncommittedSelectors.hunkSelection.fileCheckStatus(state, { stackId, path });
}
The selector returns 'checked' | 'indeterminate' | 'unchecked' based on the current hunkSelection entries (lines 99-124).
Handling Backend Updates
// Internally triggered by RTK-Query cache update:
store.dispatch(uncommittedActions.update({ assignments, changes }));
The updateAssignments helper walks old selections and maps them to fresh assignments, preserving line selections when possible (lines 29-50).
Key Files
| File | Role | Link |
|---|---|---|
apps/desktop/src/lib/worktree/worktreeService.svelte.ts |
RTK-Query endpoint that streams uncommitted changes from the backend. | view |
apps/desktop/src/lib/selection/uncommitted.ts |
Redux slice (uncommittedSlice) holding tree changes, hunk assignments, and user selections plus reducers and selectors. |
view |
apps/desktop/src/lib/selection/uncommittedService.svelte.ts |
Thin wrapper that injects the slice's actions/selectors into Svelte components. | view |
packages/ui/src/lib/utils/testIds.ts |
Test-ID constants for the Uncommitted Changes UI (uncommitted-changes-file-list). |
view |
apps/desktop/src/lib/dependencies/dependencies.ts |
Provides the DependencyError and HunkDependencies types used by the worktree payload. |
view |
apps/desktop/src/lib/hunks/hunk.ts |
Definition of HunkAssignment, HunkHeader, and error helpers. |
view |
These files together implement GitButler's uncommitted file tracking, turning a live backend stream into a consistent, selectable UI state.
Summary
- GitButler uncommitted file tracking mechanism relies on a streaming architecture where the backend pushes worktree changes via the
project://<id>/worktree_changesevent stream. - The
WorktreeServicenormalizes incoming payloads into entity adapters forTreeChangeandHunkAssignment, invalidating theDifftag to trigger UI refreshes. - The
uncommittedRedux slice maintains three entity collections:treeChangesfor file diffs,hunkAssignmentsfor selectable line ranges, andhunkSelectionfor user choices. - The
updateAssignmentshelper preserves partial line selections when the backend refreshes data, ensuring the UI state survives file modifications. - Selectors like
fileCheckStatusandhunkCheckStatuscompute tri-state checkboxes (checked,indeterminate,unchecked) for the Uncommitted Changes panel.
Frequently Asked Questions
How does GitButler keep the uncommitted changes list synchronized with the filesystem?
GitButler maintains synchronization through a persistent event stream. The WorktreeService subscribes to the backend endpoint project://<id>/worktree_changes, which pushes updates whenever the working directory changes. These updates flow through RTK-Query into the Redux store, where entity adapters normalize the data and automatically invalidate cached diffs to refresh the UI.
What happens to my hunk selections when the file changes on disk?
The system preserves your selections through the updateAssignments helper function in apps/desktop/src/lib/selection/uncommitted.ts. When new data arrives from the backend, this helper walks your existing hunkSelection entries and maps them to the fresh assignments using stable IDs or composite keys. It preserves individual line selections where possible and removes only those selections that no longer have matching assignments.
How does the UI determine whether a file checkbox is checked, unchecked, or indeterminate?
The uncommittedSelectors module provides specialized selectors like fileCheckStatus, folderCheckStatus, and stackCheckStatus that compute the tri-state value. These selectors examine the underlying hunkSelection entities: if all hunks in a file are selected, the status is checked; if some but not all are selected, it returns indeterminate; if none are selected, it returns unchecked.
Where does the uncommitted changes data originate in the backend?
The data originates from GitButler's Rust backend, which monitors the working directory for changes. The frontend receives this data through the worktree_changes event stream as WorktreeChanges payloads. These payloads contain the raw changes (file diffs), assignments (hunk metadata), and any dependency errors, which the frontend then normalizes into the Redux store.
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 →