Skip to content

fix(desktop): Git Tree follows ref moves that keep the top commit (#266) - #267

Merged
devlint merged 6 commits into
mainfrom
fix/git-tree-stale-after-ref-moves
Nov 3, 2026
Merged

devlint merged 6 commits into
mainfrom
fix/git-tree-stale-after-ref-moves

Conversation

@devlint

@devlint devlint commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Fixes #266.

Why

The Git Tree kept stale branch labels, lanes and colors (or hid a branch) after refs moved while the newest commit stayed the same: main reset onto origin/main, a branch created on an existing commit, checkout of a previous branch, main reset onto another branch's commit. Only a restart fixed it. Two caches keyed on the top commit alone were the cause:

  • Log cache (useGitRepo.ts loadLog): when the first page's top hash matched, the fresh page — with its fresh %D decorations — was thrown away, and LOG_CACHE was restored on the same test. The earlier fixes forced a reload after in-app actions, but the watcher (terminal/IDE git), the integrated terminal, undo/Time Machine, PR checkout and the rebase banner still refreshed without force.
  • Graph layout cache (CommitGraph.vue): the fingerprint was count + first + last hash + branch + WIP, and the main/master lane-0 pin was re-detected only when the top commit changed.

What

  • loadLog compares the fresh first page against the held log and the cache by hash and refs (samePageDecorations). On any difference it takes the fresh rows, drops that repo's LOG_CACHE and re-prefetches; an idle poll keeps the same array and spawns nothing extra.
  • A head/refs watcher event now does one forced reload (repoRefresh(true)), so ref moves deeper than the first page are seen too.
  • Ordering: each loadLog gets a request number and only the latest applies; a superseded forced reload hands its force to the next call. A fetch landing after a repo switch is dropped.
  • Pagination: replacing the log invalidates an in-flight loadMoreLog (_logGen, _prefetchToken), which could previously append a page at a stale --skip offset and leave a gap that was then cached.
  • The branch list (used by the graph's labels) is reloaded when loadLog sees rows change, if it was already loaded — this also covers the poller path when the live watcher is off. A list landing after a repo switch is ignored.
  • New utils/graphLayoutPin.ts: refs signature (FNV-1a hash, one pass), trunk detection, and the rule "keep the pin only on a pure append". CommitGraph uses it, so lanes and the trunk follow ref moves.

Tests

  • useGitRepo-log-ref-moves.test.ts (9, real git repos behind a mocked IPC): reset of main under an unchanged top, new branch on an existing commit, idle poll keeps the array, no stale cache restore after switching repos, in-flight page doesn't leave a gap, out-of-order results, superseded forced reload still sees a deep branch, branch list reloaded on change but not on an idle poll / not loaded when lazy. Most fail against main.
  • graphLayoutPin.test.ts: the issue's cases 1 and 3, main moved onto another branch's commit, new branch, no-change cache hit, append keeps the pin, origin/main as trunk, signature size with 5000 tags.
  • Full desktop vitest green; vue-tsc clean.

Before merging

  • Not clicked through in the app. Please replay @t1gu1's scenarios from the issue.
  • Performance trade-off: every refs event is now a forced reload, which drops the log cache and restarts the background prefetch (up to 5000 commits in 500-row pages). After an in-app action that already forced its own reload, the watcher event does it a second time. Worth profiling on a repo with more than 5000 commits.
  • Known limit: a non-forced poll still only compares the first page; deep ref moves are caught through the forced watcher reload.

Resetting main onto origin/main, creating a branch on an existing commit,
checking out a previous branch or resetting main onto another branch's commit
left the Git Tree with stale labels, lanes and colors until a restart.

- loadLog: the canonical fast paths (keep the held log, restore LOG_CACHE)
  compared only the top commit hash. They now compare the fresh first page by
  hash and decoration; on any difference the fresh entries win, the repo's
  cache is dropped and re-prefetched. A user scrolled deeper gets a refetch
  down to the viewed depth, as a forced reload does. Idle polls keep the held
  array and spawn nothing extra.
- Pagination race: replacing the log now invalidates in-flight loadMoreLog
  pages (fetched at the old --skip offset) so no gap is appended and cached.
- CommitGraph: the layout fingerprint includes a one-pass decoration
  signature, and the trunk pin is re-detected when a ref moves among the
  pinned rows, not only when the top commit changes. Infinite-scroll appends
  still keep the pin. Logic extracted to utils/graphLayoutPin.ts.
- App.vue: the head/refs watcher and the reset-to-origin path reload the
  branch list the graph labels from.
- loadLog: only the latest request applies its result, so an older poll
  result landing after a newer one can't reinstate stale decorations. A
  superseded forced reload hands its force to the request that applies; the
  deep refetch respects both.
- When loadLog takes changed rows, it reloads the branch list itself if it was
  already loaded (lazy list untouched). The poller path now refreshes the
  labels CommitGraph resolves against props.branches; App.vue's explicit
  calls are gone.
- head/refs watcher: forced reload via repoRefresh(true), since decorations
  may have moved below the first page; the redundant second loadLog is gone.
- refsSignature: 32-bit FNV-1a plus decorated-row count instead of
  concatenating every decorated row (prefix signature hashed too).
- Dead TrunkPin.secondaryHashes and redundant length checks removed.
@devlint

devlint commented Nov 3, 2026

Copy link
Copy Markdown
Owner Author

QA and perf before merge

Replayed the scenarios from #266 in pnpm dev:web, with the git operations run from a shell so they go through the watcher, the same way they reached it from a terminal:

Scenario main This PR
Local commits on main → new branch feat, commit, push → git reset --hard origin/main main label stays on the old local commit main on origin/main, feat in its own lane
Branch created on an existing commit (git branch side <old>) — label appears
New branch feat2 with a pushed commit, then checkout of the previous branch — feat2 stays visible
main reset onto another branch's commit — main moves to that commit and takes lane 0; origin/main stays labelled

No restart was needed for any of them. The first scenario reproduces the bug on main.

Performance. One refs event now costs a forced reload: a first page of 100 rows plus the background prefetch (10 pages of 500, capped at 5000).

  • Synthetic 6000-commit repo: 11 git log requests, about 0.65 s, idle-scheduled.
  • Replaying the same git log sequence on a real 74,629-commit repo (897 refs, commit-graph): about 750 ms of git time in total.
  • The prefetch token cancels a superseded prefetch between pages.

Accepted as the cost of correct labels.

Merged main into the branch (clean merge). Desktop tests pass (2062), vue-tsc is clean, and I added the CHANGELOG entry.

@devlint
devlint merged commit 0c2f3e1 into main Nov 3, 2026
9 checks passed
@devlint
devlint deleted the fix/git-tree-stale-after-ref-moves branch November 3, 2026 09:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

It still miss some Git Tree renrender

1 participant