Skip to content

Fix phantom "detached HEAD" entry stuck in the sidebar - #569

Merged
hannesa2 merged 1 commit into
gitx:masterfrom
goneng:fix-phantom-detached-head
Aug 11, 2026
Merged

hannesa2 merged 1 commit into
gitx:masterfrom
goneng:fix-phantom-detached-head

Conversation

@goneng

@goneng goneng commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Rebasing a series of local branches from a terminal while GitX watched the
repository leaves a "detached HEAD" row permanently at the top of the BRANCHES
list, long after every rebase has finished and HEAD is attached again. Selecting
other branches does not clear it, because by then the row is no longer backed by
the model at all. Confirmed by instrumenting reloadRefs and the sidebar, the
chain is: reloadRefs legitimately injects a synthetic HEAD ref while a rebase
has HEAD detached, and its title sorts first in BRANCHES; the sidebar's observers
then re-select a captured row index across a reloadData that has already
dropped the selection, so the stale index lands on the newly inserted row and
silently assigns currentBranch = HEAD; when the rebase ends, the prune loop
removes the ref but only guards headRef, leaving currentBranch dangling, and
nothing reconciles it because the FSEvent refresh path calls reloadRefs and only
reloadRefs; finally selectCurrentBranch feeds that dangling spec into
addRevSpec:, rebuilding the row as an orphan that no branches KVO removal can
ever delete. The commit message carries the step-by-step detail.

  • Re-select by item rather than by row index in the currentBranch and refs
    observers, via a new reloadPreservingSelection helper. This keeps 6e92c1a's
    intent of recomputing the checked-out badge on every refresh, without silently
    reassigning the user's selected branch whenever a row is inserted or removed
  • Reset a pruned currentBranch through readCurrentBranch at the end of
    reloadRefs, so an externally moved or deleted HEAD heals itself. Guarded on the
    branch actually having been pruned, since setCurrentBranch: reaches
    reloadRefs again through updateHistory
  • Look the row up with itemForRev: in selectCurrentBranch, so a sidebar row can
    never outlive its model entry; row creation stays in the branches KVO insertion
    path
  • Call readCurrentBranch after reloadRefs in pullBranch:fromRemote:rebase:,
    matching checkout, merge, cherry-pick, reset and rebase. GitX's own Pull and
    Rebase runs on a background queue, so it has the same mid-rebase window

Test plan

Reproduced first on an instrumented build against a scratch repo to confirm each
link in the chain, then re-run after the fix:

  • External git checkout --detach followed by re-attach: the row appears while HEAD
    is genuinely detached and disappears afterwards. Before the fix it survived, with
    currentBranch stuck at the HEAD spec and the scope bar reading "detached HEAD".
  • Delete the branch GitX has selected, from a terminal: the row is removed and
    currentBranch self-heals to the checked-out branch instead of dangling.
  • A five-branch external git pull --rebase origin main loop, and a conflicted
    rebase followed by git rebase --abort: HEAD rows created and removed in balance,
    no dangling currentBranch at any point, and the sidebar selection never moves on
    its own.

More Info

See attached screenshot

gitx-PR_#569_Detached-HEAD

Rebasing a series of local branches from a terminal while GitX watched the
repository left a "detached HEAD" row permanently at the top of the BRANCHES
list, long after every rebase had finished and HEAD was attached again.
Selecting other branches did not clear it, because by then the row was no
longer backed by the model at all.

The full chain, confirmed by instrumenting reloadRefs and the sidebar:

1. reloadRefs injects a synthetic "HEAD" ref while isHEADDetached is true,
   which git rebase makes true for its whole run. An FSEvent refresh landing
   mid-rebase therefore adds a legitimate, transient "detached HEAD" row.
   Its title starts with a curly quote, so it sorts first in BRANCHES.

2. The sidebar's currentBranch and refs observers captured selectedRow as an
   index, called reloadData, then re-selected that same index. reloadData
   drops the selection entirely (observed: selectedRow becomes -1), so the
   forced re-select always fires a selection change - and because the new row
   was inserted at the top of BRANCHES, the stale index now denoted a
   different item. With the first branch row selected, that item was the
   phantom itself, so outlineViewSelectionDidChange: assigned
   currentBranch = "HEAD" with no user interaction.

3. When the rebase finished, reloadRefs correctly pruned "HEAD" and the
   branches KVO removal deleted the row, but the prune loop only guarded
   headRef, never currentBranch. Nothing else reconciles currentBranch after
   an external HEAD change: the FSEvent refresh path calls reloadRefs and
   only reloadRefs, while readCurrentBranch is invoked solely by GitX's own
   HEAD-moving operations. currentBranch was left dangling at a spec absent
   from repository.branches.

4. selectCurrentBranch fed that dangling spec into addRevSpec:, rebuilding
   the row as an orphan. The sidebar only deletes rows in response to a
   branches KVO removal, and the model had already removed this one, so no
   removal could ever fire for it again.

Changes:

- Re-select by item instead of by row index in both sidebar observers, via a
  new reloadPreservingSelection helper. This keeps 6e92c1a's intent of
  recomputing the checked-out badge on every refresh, without reassigning the
  user's selected branch behind their back.
- Reset a pruned currentBranch through readCurrentBranch at the end of
  reloadRefs, so an externally moved or deleted HEAD heals itself. Guarded on
  the branch actually having been pruned, since setCurrentBranch: reaches
  reloadRefs again through updateHistory.
- Look the row up with itemForRev: in selectCurrentBranch, so a sidebar row
  can never outlive its model entry. Row creation stays in the branches KVO
  insertion path.
- Call readCurrentBranch after reloadRefs in pullBranch:fromRemote:rebase:,
  matching checkout, merge, cherry-pick, reset and rebase. GitX's own Pull
  and Rebase runs on a background queue, so it has the same mid-rebase window.

Verified against a scratch repo: an external detach/re-attach, deleting the
selected branch externally, a five branch external pull --rebase loop, and a
conflicted rebase followed by --abort. The row still appears while HEAD is
genuinely detached, always disappears afterwards, currentBranch never dangles,
and the sidebar selection stays put throughout.
@hannesa2
hannesa2 merged commit 084923c into gitx:master Aug 11, 2026
3 checks passed
@goneng
goneng deleted the fix-phantom-detached-head branch August 11, 2026 19:15
@goneng goneng mentioned this pull request Aug 18, 2026
1 task done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants