* Squashed commits - refactor - commit design doc * docs: add why-comments to conflict resolution code and track conflicts on open - Add explanatory comments throughout conflict resolution code covering safety constraints, architectural boundaries, and compatibility choices - Track unresolved conflicts in openConflictFile so Resolved locally state works for conflict-safe entry point - Add CLAUDE.md/AGENTS.md guideline for documenting the "why" - Add test for conflict tracking through openConflictFile
44 KiB
Right Sidebar Conflict Resolution Status
Goal
Show active conflict state during merge, rebase, and cherry-pick operations directly inside the existing Staged Changes and Changes lists in the right sidebar, similar to VS Code and GitHub Desktop.
All three operations produce the same porcelain v2 u records when conflicts arise. This design applies uniformly to all of them.
The panel should answer four questions without opening a diff:
- Is this file in a merge conflict?
- What kind of conflict is it?
- Is this conflict still unresolved right now?
- If it is no longer unresolved, what should I do next?
It should not claim more than Git can prove in the current refresh.
In v1, live unresolved conflict state must come from the current Git status output. However, the UI may also carry a clearly-labeled local session state for already-open tabs and recently-resolved rows when that state is derived from a file the user opened from a live unresolved conflict in the current session. That local state is UX scaffolding, not Git truth, and must never be labeled as if Git is still reporting an unmerged entry.
V1 does not attempt to detect conflict markers in working-tree file contents.
If the user resolves a conflict outside the app (e.g., runs git add <file> in a terminal), the u record disappears from git status and the app treats the file as no longer conflicted.
This means a file can leave the conflict state while still containing unresolved conflict markers.
This is a known limitation; v1 trusts Git's index state as the sole authority for whether a file is currently in an unresolved merge conflict.
Current State
Today the right sidebar groups files only by:
stagedunstageduntracked
Each row only knows (via GitUncommittedEntry, aliased as GitStatusEntry):
pathstatusareaoldPath?
That is enough for normal file changes, but not for merge conflict UX:
- unresolved conflicts are not represented explicitly
- the user cannot tell conflict type (
both modified,deleted by them, etc.) - the user cannot tell whether a file is currently unresolved
- conflict resolution progress becomes hard to follow once a file leaves the live
ustate - the panel does not visually prioritize conflicted files
Relevant code:
- src/main/git/status.ts
- src/shared/types.ts
- src/renderer/src/components/right-sidebar/SourceControl.tsx
Design Principles
- Keep the current sidebar structure. Users already understand
Staged ChangesandChanges. - Add conflict state to rows, but also provide a merge-resolution summary so users can scan progress quickly.
- Make unresolved conflicts impossible to miss.
- Show only conflict state that Git can prove in the current refresh.
- Use the same file in both sections when Git says both staged and unstaged states exist.
- When the UI shows session-local post-conflict state, label it explicitly as local UI state rather than current Git conflict truth.
Proposed UX
0. Merge summary
When at least one unresolved conflict exists, show a compact merge summary above the normal sections.
Recommended presentation:
Merge conflicts: 3 unresolved(count reflects only liveu-record conflicts, notResolved locallyrows)- the label should reflect the active operation when detectable:
Merge conflicts: 3 unresolvedwhenMERGE_HEADexistsRebase conflicts: 3 unresolvedwhenREBASE_HEADexistsCherry-pick conflicts: 3 unresolvedwhenCHERRY_PICK_HEADexistsConflicts: 3 unresolvedas a fallback when no ref file is found or multiple exist
- operation detection uses
fs.existsSyncon.git/MERGE_HEAD,.git/REBASE_HEAD, and.git/CHERRY_PICK_HEADin the main process, performed alongside the existing status poll (note: there is a race between thegit statuscall and thefs.existsSynccall — the HEAD file may not yet exist or may already be cleaned up — in that case the operation falls back to'unknown'for one poll cycle, which is acceptable) - secondary action:
Review conflicts - optional tertiary hint:
Resolved files move back to normal changes after they leave the live conflict state
Why:
- users in a merge flow think first in terms of “how many conflicts are left”
- this preserves the existing section structure without forcing conflict work to compete visually with every other file change
- it gives the user a stable place to orient before scanning individual rows
V1 may keep Changes and Staged Changes as the main lists, but unresolved conflicts should also be discoverable through this merge summary entry point.
Review conflicts must have a concrete v1 behavior:
- it opens a lightweight conflict-review tab scoped to the current live unresolved conflict set
- the tab lists only unresolved conflict rows, not ordinary diffs
- each item opens the same conflict-safe single-file entry point described in section 5
- if the unresolved set changes later, the already-open review tab may keep showing the snapshot it was opened with, as long as the UI labels it as a snapshot and offers a refresh or reopen action
- if all conflicts in the snapshot are resolved and the list becomes empty, the tab must show an explicit "all conflicts resolved" state with a dismiss action, not a blank list
- the "all conflicts resolved" state should also offer a link back to
Source Controlto continue the merge workflow
V1 does not require a merge-aware three-way diff queue. A lightweight conflict list view is sufficient.
Information architecture decision for v1:
Review conflictsbelongs toSource Control, notChecks- it is launched from
Source Controlinto the editor area as a dedicated review tab, similar toOpen all diffs - do not create a new permanent right-sidebar top-level tab for conflicts in v1
Why:
- merge-conflict review is source-control work, not CI/PR status
- the app already uses editor tabs as the place where review workflows expand beyond the sidebar
- this improves the conflict workflow without fragmenting navigation
1. File row anatomy
Each file row keeps the current filename and directory, and may also show a normal status letter when one exists for the current row model. Conflict rows add explicit conflict UI instead of pretending they are ordinary file states.
Row layout:
[icon] filename dir [status letter?] [conflict badge]
Conflict badge values for v1:
UnresolvedResolved locally
Conflict subtype text:
Both modifiedBoth addedDeleted by usDeleted by themAdded by usAdded by themBoth deleted
Recommended presentation:
- badge is compact and high-contrast
- subtype appears as muted secondary text under or beside the filename
- unresolved uses destructive color
Resolved locallyuses a quieter success or accent treatment and must include tooltip/help text that it is derived from the current session, not from live Git conflict output- if a conflict row does not have a meaningful ordinary status letter, omit the letter instead of inventing one
- conflict rows may replace ordinary status letters entirely when showing a normal status letter would create contradictory meaning
Action-oriented helper text:
Both modified->Open and edit the final contentsBoth added->Choose which version to keep, or combine themDeleted by us->Decide whether to restore the fileDeleted by them->Decide whether to keep the file or accept deletionAdded by us->Review whether to keep the added fileAdded by them->Review the added file before keeping itBoth deleted->Resolve in Git or restore one side before editing
Non-goal for v1:
- carrying conflict history forward after the
urecord disappears
Clarification:
- v1 may show
Resolved locallyonly when the app can tie that row or tab to a file that was opened from a live unresolved conflict during the current session - v1 should not invent historical conflict state for files the user never interacted with in the current session
2. Section behavior
Keep the existing sections, but add a merge-resolution summary above them and order conflicted files first within Changes and Staged Changes.
Changes
This section should contain:
- normal unstaged files
- unresolved conflicts
- optionally, recently resolved files from the current session that still have unstaged changes and need review
Expected labels:
- unresolved conflict:
Unresolved - recently resolved in current session:
Resolved locally
This matches the user expectation: the working tree still needs attention.
Staged Changes
This section should contain:
- normal staged files
- optionally, recently resolved files from the current session that were staged after conflict resolution
Resolved locally badge lifecycle is governed by the state machine defined in the Representing resolved conflicts section.
This state is tied to the file's presence in the current sidebar workflow, not to whether its tab happens to remain open.
3. Summary counts
Section headers should surface unresolved conflict counts when present.
Examples:
Changes 5 · 2 conflicts- merge summary:
Merge conflicts: 2 unresolved
This should remain terse. The row badge carries the detailed meaning.
4. Row actions
Conflict-aware row actions:
- unresolved in
Changes: open editor, no discard shortcut, no stage shortcut - resolved locally in
Changes: open editor, stage is allowed, discard is hidden in v1 (discarding a just-resolved conflict file can silently re-create the conflict or lose the resolution — v1 does not have the UX to explain this clearly, so hiding it is the safe default) - resolved locally in
Staged Changes: open editor, unstage is allowed
Reasoning:
- unresolved conflicts are higher risk than normal edits
- a one-click discard on an unresolved conflict is too easy to misfire
- a one-click stage on an unresolved conflict can immediately erase the sidebar conflict signal because Git stops reporting the
urecord aftergit add - users should resolve conflicts in the editor first, then stage from a state that still preserves continuity that this file just came out of conflict resolution
5. Diff/editor entry point
Opening a conflicted file should preserve the current navigation flow, but v1 must not assume the existing two-way diff backend can render unresolved conflicts correctly.
For unresolved conflicts, the safe requirement is:
- clicking the row opens the existing file entry point for the file
- the header reflects the conflict badge and subtype when that metadata exists
- unresolved conflicts must not be routed into a misleading two-way diff view
- if the app cannot render a merge-aware view in v1, open the editable file view with conflict metadata instead of the normal diff view
- any fallback state must be explicit and explain that merge-aware diff rendering is not available yet
- the opened view should include action-oriented guidance for the current conflict subtype rather than only Git terminology
Do not claim VS Code style merge presentation in v1 unless the diff backend is updated to read conflict stages explicitly.
This applies to every entry point, including section-level Open all diffs.
Required routing rule for v1:
- if the
GitStatusEntryfor the row hasconflictStatus === 'unresolved', never call the normal uncommitted diff opener for that row - instead, route to a conflict-safe entry point that opens either:
- the editable working-tree file view, when a working-tree file exists
- a conflict details panel or read-only placeholder view, when no working-tree file exists
Required bulk-review behavior for v1:
- the product must not present a bulk action that appears to review every file while silently excluding unresolved conflicts
- if a section-level
Open all diffsaction remains, its label or adjacent copy must make the scope explicit when unresolved conflicts are present - preferred v1 behavior:
- keep
Open all diffsfor normal change review - add
Review conflictsfrom the merge summary when unresolved conflicts exist - if both actions are shown together, the UI must explain the split clearly
- keep
- acceptable fallback behavior:
Open all diffsopens normal diffs only- unresolved conflicts are excluded from the combined diff set
- the trigger and resulting view both state that conflicted files are reviewed separately
- if rows are excluded, the combined-diff tab state must carry an explicit
skippedConflictspayload so the notice is deterministic and does not depend on reconstructing the skipped set later from live status alone - if every candidate row for a given combined-diff open is excluded, the tab must render a conflict-specific state rather than a generic
No changes to display - that excluded-only state must list the conflicted paths and provide a direct
Review conflictsaction
Definition of Review conflicts in this context:
- if invoked from the merge summary, open the unresolved-conflict review tab for the full live unresolved set
- if invoked from an excluded-only combined-diff state, open the unresolved-conflict review tab preloaded with that tab's stored skipped-conflict snapshot
- the action must never route unresolved conflicts into the ordinary two-way diff viewer
This requirement is intentionally strict because the current diff pipeline reads normal index and worktree content, not merge stages, and because bulk actions must not undermine user trust in the review queue.
Examples:
app.tsx · Unresolved conflict · Both modified
This keeps the sidebar and editor consistent.
6. Conflict kinds without a working-tree file
Not every unresolved conflict can be opened as a normal editable file.
Examples:
both_deleted- some
deleted_by_us/deleted_by_themstates, depending on which side leaves a working-tree file behind
For these cases, v1 must not attempt to open the normal editable file view and then show a generic file-read error.
Instead, the entry point should show an explicit conflict state such as:
This file is in an unresolved merge conflict. No working-tree file is available to edit.- subtype text such as
Both deleted - guidance to resolve via Git or restore one side before editing
- action-oriented next step when possible, such as
Restore one side, then reopen this file
This can be a lightweight placeholder screen in the editor area. The important requirement is that the state is conflict-aware and not presented as a broken file open.
Required state shape for v1:
- opening a non-editable unresolved conflict must not reuse plain
mode: 'edit' - the opened tab state must distinguish
conflict-placeholderfrom ordinary editable files and ordinary diffs - the placeholder state must carry at least:
pathconflictStatusconflictKindmessage- optional
guidance
Proposed Data Model
Extend GitUncommittedEntry (the base type behind GitStatusEntry) with conflict metadata, and extend OpenFile (the editor tab state in src/renderer/src/store/slices/editor.ts) with conflict-aware metadata instead of relying on sidebar rows as the only source of truth.
export type GitConflictKind =
| 'both_modified'
| 'both_added'
| 'both_deleted'
| 'added_by_us'
| 'added_by_them'
| 'deleted_by_us'
| 'deleted_by_them'
export type GitConflictResolutionStatus = 'unresolved' | 'resolved_locally'
export type GitConflictStatusSource = 'git' | 'session'
// Extend GitUncommittedEntry (src/shared/types.ts)
// Currently: { path, status, area, oldPath? }
// GitStatusEntry is an alias for GitUncommittedEntry; extending the base extends both.
// Note: conflictHint is NOT included here. The main process returns only
// Git-derived data. Hint text is derived in the renderer from conflictKind
// using a CONFLICT_HINT_MAP lookup (see Renderer hint derivation below).
//
// conflictStatusSource is NOT set by the main process. The main process
// returns only conflictKind and conflictStatus (always 'unresolved') for
// live u records. The renderer sets conflictStatusSource: 'git' when
// populating from IPC data, and 'session' when applying Resolved locally
// state from trackedConflictPaths. This keeps the main process free of
// session-awareness while letting the renderer distinguish the two sources.
export type GitUncommittedEntry = {
path: string
status: GitFileStatus
area: GitStagingArea
oldPath?: string
conflictKind?: GitConflictKind
conflictStatus?: GitConflictResolutionStatus
conflictStatusSource?: GitConflictStatusSource
}
// Active operation detected by the main process alongside status polling.
// Used by the renderer to label the merge summary correctly.
export type GitConflictOperation = 'merge' | 'rebase' | 'cherry-pick' | 'unknown'
// Renderer hint derivation:
// The renderer maps conflictKind to a user-facing hint string using a
// CONFLICT_HINT_MAP constant (e.g., both_modified -> 'Open and edit the
// final contents'). This keeps UI copy out of the main process parser.
export type OpenConflictMetadata = {
conflictKind: GitConflictKind
conflictStatus: GitConflictResolutionStatus
conflictStatusSource: GitConflictStatusSource
}
export type OpenConflictPlaceholder = OpenConflictMetadata & {
kind: 'conflict-placeholder'
message: string
guidance?: string
}
export type OpenConflictEditable = OpenConflictMetadata & {
kind: 'conflict-editable'
}
export type ConflictReviewState = {
kind: 'conflict-review'
source: 'live-summary' | 'combined-diff-exclusion'
/** Timestamp (ms since epoch) when the snapshot was taken. The renderer
* derives the display label at render time (e.g., '3 unresolved conflicts
* at 2:34 PM') from snapshotTimestamp + entries.length, keeping UI copy
* out of stored state — consistent with the CONFLICT_HINT_MAP approach. */
snapshotTimestamp: number
entries: ConflictReviewEntry[]
}
export type CombinedDiffSkippedConflict = {
path: string
conflictKind: GitConflictKind
}
export type ConflictSummaryState = {
unresolvedCount: number
paths: string[]
}
export type ConflictReviewEntry = {
path: string
conflictKind: GitConflictKind
}
// Extend OpenFile (src/renderer/src/store/slices/editor.ts)
// Current shape:
// { id, filePath, relativePath, worktreeId, language, isDirty,
// mode: 'edit' | 'diff', diffSource?, branchCompare?,
// branchOldPath?, combinedAlternate?, combinedAreaFilter?, isPreview? }
//
// OpenFile uses a discriminated union on `mode` so that conflict-review tabs
// do not require a filePath (they are list views, not file views).
// Normal file tabs ('edit' | 'diff') keep filePath required.
// Add:
type OpenFileBase = {
id: string
worktreeId: string
isPreview?: boolean
}
type OpenFileTab = OpenFileBase & {
mode: 'edit' | 'diff'
filePath: string
relativePath: string
language: string
isDirty: boolean
diffSource?: DiffSource
branchCompare?: BranchCompareSnapshot
branchOldPath?: string
combinedAlternate?: CombinedDiffAlternate
combinedAreaFilter?: string
// conflict fields for single-file tabs
conflict?: OpenConflictEditable | OpenConflictPlaceholder
skippedConflicts?: CombinedDiffSkippedConflict[]
}
type OpenConflictReviewTab = OpenFileBase & {
mode: 'conflict-review'
/** id for conflict-review tabs uses a deterministic scheme:
* `conflict-review-${worktreeId}` — at most one conflict-review tab
* per worktree. Opening a new review replaces the existing one. */
conflictReview: ConflictReviewState
}
export type OpenFile = OpenFileTab | OpenConflictReviewTab
Notes:
conflictKinddescribes the merge shapeconflictStatusdescribes whether the UI is showing a live unresolved state or a session-local resolved stateconflictStatusSourcedistinguishes live Git truth from session-local continuity state- action-oriented hint text (e.g.,
Open and edit the final contentsforboth_modified) is derived at render time fromconflictKindvia the renderer-sideCONFLICT_HINT_MAPconstant — it is not a field onGitUncommittedEntryand is never returned by the main process - treat a row as conflicted when
conflictStatusis present - the
statusvalue on unresolved conflicts is a rendering compatibility choice for existing icon/color plumbing, not a semantic claim — the conflict badge carries the real semantics - opened editor tabs are a separate state domain from live git-status rows and need their own conflict metadata
Review conflictsis also an editor-tab state, but it is not a diff tab and should not be forced intomode: 'diff'- v1 therefore needs an explicit editor-tab representation for conflict review rather than overloading combined-diff state
Compatibility rule for non-upgraded consumers:
- any consumer of
GitStatusEntrythat has not been upgraded to readconflictStatusmay still rendermodifiedstyling, but must not offer file-existence-dependent affordances (diff loading, drag payloads, editable-file opening) for unresolved conflicts - this affects file explorer decorations, tab badges, and any other surface outside
Source Control - any consumer of
OpenFilethat accessesfilePathmust narrow onmodefirst, becauseOpenConflictReviewTabdoes not carryfilePath— code that assumesopenFile.filePathexists without checkingmodewill break at compile time once the discriminated union is in place
Known limitation:
- passive file explorer and tab badge surfaces may show
modifiedstyling for unresolved conflicts in v1
Explicit v1 scope decision:
- required to upgrade in v1:
Source Control, editor tab state, single-file conflict opening, section-level bulk actions, and any shared open-file helper they depend on - allowed to defer in v1: passive decorations in file explorer and tab-strip visuals
- not allowed to defer in v1: any entry point that can directly open an unresolved-conflict file into the ordinary diff or ordinary editable path without the conflict-aware routing rules
- not allowed to defer in v1: the dedicated editor-tab state and open action for
Review conflicts
Git Parsing Plan
Source of truth
Use git status --porcelain=v2 --untracked-files=all as the primary source, but start parsing u records in addition to 1, 2, and ?.
Today status.ts ignores unmerged records entirely.
Performance
Parsing u records adds no meaningful overhead — they use the same line-by-line parsing loop as 1/2/? records, and the number of unmerged entries during a typical merge is small (usually single digits). The filesystem existence check for ambiguous conflict kinds (deleted_by_us, deleted_by_them, etc.) adds one fs.existsSync call per ambiguous entry, which is negligible within the 3-second polling interval.
If the filesystem existence check throws (permissions error, unmounted path, etc.), default to status: 'modified'. This is the safer fallback because it avoids suppressing the conflict row from the sidebar and avoids presenting a deleted status that could mislead the user into thinking the file is gone when the check simply failed. The conflict badge and subtype still carry the real semantics regardless of the status fallback.
Mapping unmerged records
Porcelain v2 unmerged XY states should map like this:
UU->both_modifiedAA->both_addedDD->both_deletedAU->added_by_usUA->added_by_themDU->deleted_by_usUD->deleted_by_them
V1 should also map every unresolved u record to:
area: 'unstaged'status: determined by whether a working-tree file exists for the conflict kind:'modified'for kinds that always leave a working-tree file:both_modified,both_added'deleted'for kinds that never leave a working-tree file:both_deleted- for
deleted_by_us,deleted_by_them,added_by_us,added_by_them: check whether the working-tree file exists at parse time and use'modified'if it does,'deleted'if it does not (foradded_by_us/added_by_them, Git typically does leave a working-tree file, so the check is defensive — the primary ambiguity is indeleted_by_*variants where the merge strategy determines whether the surviving side's content is written to the worktree)
Why:
- the current sidebar, tab decorations, and file explorer already expect a
GitFileStatus modifiedis the least misleading compatibility fallback for conflicts where a working-tree file existsdeletedis a better fallback when no working-tree file exists because calling itmodifiedwould be contradictorydeleted_by_us/deleted_by_themand theadded_by_*variants do not have a guaranteed working-tree file — Git's behavior depends on the merge strategy and the specific conflict — so the parser must check the filesystem rather than hardcoding an assumption- the conflict badge/subtype still carries the real semantics in all cases
- v1 should not invent new single-letter codes for unmerged rows without a broader status-system redesign
Representing unresolved conflicts in the existing sections
Unresolved conflicts should be emitted as area: 'unstaged' entries with:
status: determined by working-tree file existence (see Mapping unmerged records above)conflictStatus: 'unresolved'conflictKind: ...
Why:
- the user still needs to act in the working tree
- this keeps the existing panel layout intact
- it matches the mental model of “this is still pending”
Ordering requirement:
- conflicted entries sort before ordinary entries within
Changes Staged Changeshas no conflict rows in v1 because unresolvedurecords are emitted only intounstaged
Representing resolved conflicts
In v1, live Git status must stop representing a file as conflicted after Git stops reporting a u record.
However, the UI may carry forward a temporary Resolved locally state for files the user opened from a live unresolved conflict in the current session.
Why:
git status --porcelain=v2no longer marks the file as unmerged after resolution is staged- users still need continuity that the file they were just resolving is now in the review-or-stage step of the same workflow
- limiting this to files the user explicitly opened in the current session keeps the scope auditable and avoids broad historical inference
Corollary:
- unresolved rows must not expose a
Stageshortcut, because that shortcut can remove the only live conflict signal before the user has actually completed review of the file Resolved locallyrows may expose normal safe actions again because the user is now in the post-resolution workflow stage
Resolved locally state machine
Store location
trackedConflictPaths is a Map<string, Set<string>> keyed by worktreeId, stored in the renderer-side Zustand git store (the same store that holds gitStatus). It is not component-local state — it must survive across re-renders of SourceControl and be accessible to both the click handler that adds paths and the polling hook that checks for u-record disappearance. Keying by worktree ensures paths are scoped correctly when the app has multiple worktrees open.
The map is populated by the Source Control click handler and read by the status-polling reconciliation logic. It is never sent to the main process.
State transitions
A file enters Resolved locally through a precise sequence:
- Track: when the user clicks an unresolved conflict row in
Source Controlto open or focus a tab, record that path in thetrackedConflictPathsset. Opening the same file from the file explorer, terminal, or any non-conflict-row entry point does not add it to this set. - Transition: on the next
git statuspoll, if a path intrackedConflictPathsno longer has aurecord, mark that path asResolved locallywithconflictStatusSource: 'session'. - Re-enter: if a path currently in
Resolved locallystate reappears as aurecord (e.g., the user rangit checkout -m <file>to re-create the conflict), replace the session-local resolved state with live Git conflict state (conflictStatus: 'unresolved',conflictStatusSource: 'git'). The path remains intrackedConflictPathsso it can transition back toResolved locallyif theurecord disappears again. - Expire: clear the
Resolved locallystate when any of these happens:- the file leaves the sidebar entirely (no staged or unstaged entry)
- the app session resets (window reload, app restart)
- the file re-enters a live unresolved
ustate, which replaces the local resolved state with live Git conflict state again
- Abort: when the merge/rebase/cherry-pick operation is aborted (
git merge --abort,git rebase --abort,git cherry-pick --abort), allurecords disappear simultaneously and the operation HEAD file (.git/MERGE_HEAD, etc.) is cleaned up. On the next poll, if the detected operation changes to'unknown'(no HEAD file found) and the unresolved count drops to zero in the same poll cycle, clear the entiretrackedConflictPathsset for that worktree rather than transitioning each path toResolved locally. Abort is not resolution — showingResolved locallyon every previously-conflicted file after an abort would be misleading.
If a file's u record disappears but the path was never in trackedConflictPaths, the file simply reverts to its ordinary GitFileStatus with no Resolved locally badge.
Important consequence:
- closing a tab does not clear
Resolved locallyby itself - if a file is still present in
ChangesorStaged Changes, the continuity badge should remain visible until the file leaves the sidebar, the session resets, or the file becomes live-unresolved again
Guardrails for Resolved locally:
- only show it when the path is in
trackedConflictPathsand theurecord has disappeared - label it as local/session-derived, not as live Git conflict output
- never recreate it from polling history alone for files the user did not actively open from a conflict row
IPC Boundary
Git status parsing runs in the main process (src/main/git/status.ts). The renderer receives status data via the git:status IPC channel (src/main/ipc/filesystem.ts), which returns GitStatusEntry[] directly. The polling hook (src/renderer/src/components/right-sidebar/useGitStatusPolling.ts) stores the result in the Zustand store without field filtering.
Electron's structured clone serialization preserves all enumerable properties on returned objects. Adding new optional fields to GitUncommittedEntry (and therefore GitStatusEntry) requires no IPC layer changes -- the new fields will serialize and deserialize automatically.
Session-local state (Resolved locally tracking) lives entirely in the renderer and does not cross the IPC boundary. The main process returns only what git status reports.
The main process should also return the detected GitConflictOperation alongside GitStatusEntry[] so the renderer can label the merge summary correctly. This can be added to the existing git:status IPC response shape as an optional field.
Required renderer-store change:
setGitStatusmust treat conflict metadata changes as meaningful updates- equality checks that compare only
path,status, andareaare insufficient for this design - at minimum, renderer cache invalidation must also react to
conflictStatus,conflictKind, andconflictStatusSource - otherwise a row can remain visually stale when conflict state changes without changing its base
GitFileStatus
Sorting
Within each section, sort by:
- unresolved conflicts
- resolved locally
- normal file changes
- path name
This mirrors the urgency ordering used by editor UIs.
Visual Spec
Icons
Keep the existing file-type/status icon, but add a conflict badge rather than replacing the file icon.
Reason:
- users still need file identity and normal file-type affordances
- unresolved conflict is more important than ordinary status letters when the two signals compete
- the UI should prefer the conflict badge over a potentially misleading ordinary status letter
Badge styles
Unresolved: red background, red text emphasisResolved locally: success or accent styling with a tooltip that says the state came from this app session, not from current Git conflict output
Secondary text
Show conflict subtype in a small muted label:
Both modifiedDeleted by them
This is more useful than raw UU / UD codes.
When space allows, add helper text focused on the next decision rather than only the Git term.
Interaction Details
Hover
On hover, keep existing stage/unstage actions where they are safe, but do not hide conflict badges.
For unresolved conflicts in Changes:
- hide
Discard - hide
Stage
For Resolved locally rows:
- restore safe actions appropriate to the row's current section
- keep the badge visible until the session-local continuity state expires
Click
Clicking a conflicted row follows the routing rules defined in section 5 (Diff/editor entry point). Key constraints:
- do not reuse
openDiff(...)unchanged for unresolved conflict rows — use the conflict-aware open path - the opened tab must carry
conflictStatusandconflictKindso the header can render them without re-querying sidebar state - use the same tab identity as ordinary editable tabs; attach
conflict.kind: 'conflict-editable'metadata rather than inventing a second tab namespace - if no working-tree file exists, open a conflict-placeholder tab instead of an ordinary file tab
Review conflictsuses a separate editor-tab mode and open action; it is launched fromSource Control, not fromChecks
Tab reconciliation on status refresh:
- editable tabs: downgrade conflict metadata in place (same tab id, no duplicate)
- placeholder tabs: close or convert deterministically when the path is no longer unresolved
- conflict-review tabs: preserve their stored snapshot unless the user explicitly refreshes or reopens from the current merge summary
- tab closure alone must not clear sidebar
Resolved locallystate while the file still appears inChangesorStaged Changes
Section actions
Section-level actions follow the bulk-review rules defined in section 5 (Required bulk-review behavior for v1). The key invariant: do not leave a hidden unsafe path where single-row clicks are safe but bulk-open actions are not.
Empty state
If all remaining files are conflicted, do not show No changes detected. The normal section rendering already covers this once conflicts are included in status parsing.
Edge Cases
Rename plus conflict
Do not solve this specially in v1. Prefer showing the destination path and conflict badge.
Important constraint:
- porcelain v2
urecords do not provide rename-origin metadata like2records do - assume
oldPathis unavailable for unresolved conflicts unless a separate Git query is added later - v1 should not promise rename ancestry in conflict rows
Submodule conflicts
Porcelain v2 also emits u records for submodule conflicts. Submodule conflicts are out of scope for v1. The parser should skip u records where any of the h1/h2/h3 mode fields indicates a submodule (mode 160000) and leave them unhandled rather than presenting them with the same UX as normal file conflicts.
Binary conflicts
Use the same row badge model even if the diff viewer cannot render a normal text diff.
Implementation Plan
Phase 1a: Parsing and types
- extend shared types with conflict metadata and
GitConflictOperation - parse porcelain v2
uentries in status.ts, including filesystem existence checks for ambiguous conflict kinds (with'modified'fallback on fs errors) - detect active operation via
.git/MERGE_HEAD,.git/REBASE_HEAD,.git/CHERRY_PICK_HEADand returnGitConflictOperationalongside status entries - add renderer-side
CONFLICT_HINT_MAPconstant that mapsGitConflictKindto user-facing hint strings - update store equality checks so conflict metadata changes trigger UI updates
- add tests for parsing each porcelain v2 unmerged
XYvariant (UU,UD,DU,AA,AU,UA,DD) into the expectedconflictKind - add tests for filesystem existence check fallback behavior on error
Phase 1b: Sidebar UI
- render conflict badges and subtype text in SourceControl.tsx
- add merge-summary state derived from live unresolved entries, with operation-aware label and the concrete
Review conflictslist-view entry point defined above - add conflict helper text and action-oriented next-step messaging using renderer-side
CONFLICT_HINT_MAP - suppress
DiscardandStagefor unresolved conflicts; suppressDiscardforResolved locallyrows - add
trackedConflictPathsset to the renderer Zustand git store - add accessibility attributes:
role="status"on badges,aria-live="polite"on merge summary count - sort conflicted rows to the top of their section
- add UI tests for badge rendering, ordering, merge summary, and suppressed actions
Phase 1c: Editor and tab integration
- extend opened editor tab state with optional conflict metadata
- add a dedicated
openConflictReview(...)store action that opens the lightweight review tab fromSource Control - add a dedicated conflict-aware open action for unresolved rows instead of routing them through
openDiff(...) - add a dedicated conflict-placeholder tab for unresolved conflicts without a working-tree file
- add reconciliation logic that downgrades or clears conflict metadata on already-open tabs when live status changes
- render
Resolved locallyonly for files tracked via the state machine (seeResolved locallystate machine section) - add session-local continuity cleanup so
Resolved locallyexpires deterministically based on sidebar presence, not tab closure - add the lightweight conflict-review tab used by the merge summary and excluded-only bulk-review states
Phase 1d: Bulk actions and guardrails
- make section-level
Open all diffsexplicit about its scope, backed by storedskippedConflictstab state - add the dedicated conflict-review handoff state for bulk actions where every candidate row was excluded
- ensure any open-capable consumer that assumes file existence branches on
conflictStatus - leave passive explorer/tab decorations as plain
modifiedin v1 unless separately upgraded, but do not allow unsafe open routing from those surfaces - add tests that bulk-open paths do not route unresolved conflicts into the normal two-way diff viewer
Phase 2
- decide whether to build a merge-aware diff view based on index stages
- decide whether to expand the v1 lightweight
Review conflictslist into a fuller multi-file conflict queue or merge-aware review surface - consider a dedicated conflict filter if repos with many changes become noisy
Explicitly out of scope until a stronger design exists:
- session-based reconstruction for files the user never opened from a live unresolved conflict
- broad resolved-state reconstruction for files the user never opened from a live unresolved conflict
Test Cases
Parsing
- each porcelain v2 unmerged
XYvariant (UU,AA,DD,AU,UA,DU,UD) maps to the expectedconflictKind - unresolved conflict rows are emitted as
area: 'unstaged'withconflictStatus: 'unresolved' statusis'modified'when a working-tree file exists,'deleted'when it does not (includingboth_deletedand ambiguousdeleted_by_*/added_by_*cases)
Sidebar rendering
- unresolved
both modifiedfile appears inChangeswithUnresolvedbadge and subtype - unresolved
deleted by themfile appears inChangeswith correct subtype - merge summary shows unresolved count and a
Review conflictsaction when conflicts are present - unresolved conflicts do not show the
StageorDiscardactions - conflict rows sort above normal modified files;
Resolved locallyrows sort between unresolved and ordinary rows - changing only conflict metadata still triggers a re-render of the affected row
Editor / tab integration
- clicking an unresolved conflict row opens the conflict-aware editable-file path, not the normal unstaged diff path
- the editor header repeats the unresolved badge and subtype for a conflicted file tab
- opening a
both deletedconflict (or any conflict without a working-tree file) creates a conflict-placeholder tab, not a generic file-load failure - placeholder and editable conflict tabs show action-oriented next-step guidance
- clicking
Review conflictsfromSource Controlopens a dedicated conflict-review editor tab, not aCheckssurface and not a right-sidebar top-level tab - the conflict-review tab renders from stored snapshot entries rather than reconstructing its contents from live status on every paint
Tab reconciliation
- an already-open conflicted tab downgrades to
Resolved locallyafter theurecord disappears on the next status poll - downgrading preserves the same tab identity — no duplicate tab is created
- a stale conflict-placeholder tab is closed or converted deterministically once the path is no longer unresolved
- closing a tab does not clear
Resolved locallywhile the file still appears in the sidebar
Bulk actions
Open all diffsdoes not send unresolved conflicts through the normal two-way diff viewer- the exclusion of unresolved conflicts is explicit in both the trigger label and the resulting view
- the skipped-conflicts notice is stable for the lifetime of the combined-diff tab (sourced from stored
skippedConflicts, not live status) - when every candidate row is skipped, the dedicated conflict-review handoff state is shown instead of a generic empty state
Resolved locally lifecycle
Resolved locallyappears only for files the user opened from an unresolved conflict row in the current sessionResolved locallyclears when the file leaves the sidebar, the session resets, or the file becomes live-unresolved again- a file in
Resolved locallystate that reappears as aurecord reverts toUnresolvedwithconflictStatusSource: 'git' - after re-entering unresolved state, the file can transition back to
Resolved locallyif theurecord disappears again (path remains intrackedConflictPaths) - files that were never opened from a conflict row do not get
Resolved locallyafter theirurecord disappears - a file resolved externally (e.g.,
git addin terminal) drops theUnresolvedbadge on the next poll and reverts to ordinaryGitFileStatuswithout showingResolved locally, because the file was never opened from a conflict row - aborting the operation (
git merge --abort) clears the entiretrackedConflictPathsset — no files showResolved locallyafter an abort
Merge summary
- merge summary label reflects the active operation (
Merge conflicts,Rebase conflicts,Cherry-pick conflicts, orConflictsas fallback) - conflict-review tab empty state shows "all conflicts resolved" with dismiss action when snapshot entries are all resolved
Accessibility
- conflict badges use
role="status"and includearia-labeltext (e.g., "Unresolved conflict, both modified") so screen readers announce conflict state without requiring visual inspection - the merge summary unresolved count is a live region (
aria-live="polite") so count changes are announced - focus management (stretch goal for Phase 1b — implement badge and live-region accessibility first):
Review conflictsmoves focus to the opened conflict-review tab; closing the review tab returns focus to the merge summary action
Regression
- normal non-conflict rows keep current behavior
Recommendation
Implement the v1 design by enriching the existing row model and adding a compact merge summary above the existing sections.
That gives the app the quick scan value users need for active merge conflicts while preserving the current right-sidebar layout, avoiding unsafe fake diff behavior, and keeping the workflow legible after a file leaves the live u state.