Skip to content

fix: show repository owner avatars for workspaces on SSH devices - #735

Open
theontho wants to merge 13 commits into
penso:mainfrom
theontho:fix/remote-repo-avatars
Open

theontho wants to merge 13 commits into
penso:mainfrom
theontho:fix/remote-repo-avatars

Conversation

@theontho

@theontho theontho commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Sidebar rows for a remote (SSH) device always showed the generic GitHub mark, while the same repository on the local device showed its owner's avatar. The owner lookup only ran for endpoint 0, because it ran git config in the workspace directory on this machine, and a remote directory must never be resolved against local repositories.

This change keys avatar requests by a Checkout:

  • Checkout::Local(cwd) covers the local daemon. It uses local Git and behaves as before.
  • Checkout::Ssh { target, git_dir } covers a saved SSH device. It reads remote.upstream.url, then remote.origin.url, from the Git directory the daemon reported (worktree.key). The read goes through herdr_client::remote_config_value, the same bounded, noninteractive SSH read the pull request lookup already uses, with a 10 s budget shared by both keys.
  • Any other target (sockets, WSL, cloud connectors) gets no lookup, as before.

Worktrees share their common Git directory, so one SSH read covers every worktree of a repository on a host. Remote entries are stored per host, then per Git directory, so a sidebar row looks up its avatar without allocating. The existing 128-entry request bound applies across local and remote entries.

A remote workspace with no worktree metadata, such as a plain non-Herdr directory, still shows the mark. This matches how pull request lookups treat remote workspaces.

Following the device's connection

A failed SSH read (host down, SSH timeout, connection refused) is no longer cached as "no owner" until the app restarts. The lookup returns herdr_client::Result<Option<String>>: Ok(None) means the host was read and has no GitHub owner, which is still cached for good. An error marks the checkout Unreachable.

Remote reads follow the window's own connection to each saved SSH device (Avatars::follow, fed every poll from the endpoint's online_since):

  • Disconnected: while no endpoint on a host is connected, nothing is requested for it. Reads already queued for it never dial, and a running read is cancelled through the SSH read's cancel hook rather than waiting out its 10 s budget.
  • Reconnected: when a connection comes up with a snapshot, the host's Unreachable checkouts become due at once, and that first snapshot asks for them again. A device that comes back gets its avatars right away, with no timer to wait out.
  • Failing while connected: a read that fails while the device stays connected waits 60 s before the next sidebar update may ask again, so a host whose Git config can't be read costs at most one read per minute.
  • Another session's endpoint on the same host keeps the host connected. An endpoint pointed at a different host releases the old one.

A retry reuses its existing entry, so it never counts against the request bound as a new checkout.

Slow hosts never delay local avatars

SSH reads can take the full 10 s budget when a host stops answering, so they no longer share a worker with local Git lookups. Local lookups have their own queue and worker. SSH reads run on a pool of four readers, so one stalled host doesn't block other devices either. The 128-entry request bound still covers both queues.

Test fixes on this branch

  • f1ca89db/2e7ac189 fixed the Windows native sidebar child-width probe. main has since taken the same fix (test(sidebar): expect the child label to take the chevron column #746); after merging main, this branch differs from it only by one comment.
  • test(updater): relaunch_retries_then_alerts_when_open_keeps_failing waited 10 s for a path that sleeps 3.25 s between about a dozen process spawns, and failed twice in local just ci at load average ~67. The wait is now 30 s. This bounds only a failing run, so passing runs are no slower.
  • test(sidebar): resting_on_a_workspace_opens_its_menu_once checked "not yet open" at Instant::now() + 300 ms, measured from a later clock reading than the move it tests, so on a loaded machine the menu could open before the check ran. Those checks now poll at an instant taken before the move.

Screenshots

macOS, dark theme, with two saved SSH devices (lin-omarchy, lin-big-omarchy).

Before After
Before: remote repository rows show the GitHub mark After: remote repository rows show the owner's avatar

After the change, herdr-gpui on lin-omarchy and GRiii on lin-big-omarchy show their owners' avatars. wgsextract-cli on lin-big-omarchy keeps the mark because that host's daemon reports no worktree metadata for it (checked with herdr workspace list).

QA

  • cargo test --locked -p herdr-gpui avatars: 29 passed, 2 ignored (live).
  • avatars/tests/remote.rs covers:
    • checkout selection for SSH, local, socket, and no-metadata workspaces;
    • per-host deduplication kept apart from local paths;
    • the request bound across hosts.
  • avatars/tests/retry.rs covers:
    • an unreachable host is asked again only once the retry interval has passed, and only once while that retry is pending;
    • a host read without a GitHub owner is never asked again;
    • a retry is not refused by the request bound;
    • a local checkout never fails the lookup.
  • Live SSH read with reads_the_repository_owner_over_ssh -- --ignored on macOS arm64 against both saved devices: read owner penso (lin-omarchy, /home/mac/src/herdr-gpui/.git) and theontho (lin-big-omarchy, /home/mac/src/GRiii/.git). Against an unresolvable host it returns Err(RemoteCommand(255)), which the app retries, where before this was silently cached as "no owner".
  • avatars/tests/reconnect.rs covers:
    • a reconnected device being asked again at once, while the same connection keeps the 60 s interval;
    • a host going offline only once all its endpoints drop, with a read for it then stopping before it dials;
    • an endpoint retargeted to another host releasing the old one;
    • a read stopped by a disconnect being due on the next request.
  • avatars/tests/workers.rs covers: local and SSH requests going to separate queues, and a full SSH queue still accepting local lookups.
  • Native macOS check: built the app with just bundle release qa-menu, ran it with the existing config and saved SSH devices, and confirmed in the window that remote repository rows show owner avatars (screenshots above). This was before the retry, connection-following, and worker-split changes, which are covered by the unit tests and live read above and change nothing visible.
  • just ci on macOS arm64 at 702f02b9 (merged with main at 32b0b878): passed, 10220 tests, 0 failed.
  • just test-sidebar on macOS arm64: the child-width probe now expects 179px and all 26 child checks pass; the deliberate bad-width fixture still fails as intended. The main native test then hit its 20 s timeout at the outside-click step on this laptop, whose display can't fit the test's 1000–1200px windows (frames are skipped as "does not fit the display"); the Windows native GUI job raises its resolution, so that job is the real check.
  • Connect to a saved SSH device whose workspaces are Herdr worktrees of a GitHub repository. Confirm the repository rows show the owner's avatar.
  • Start the app while a saved SSH device is unreachable, then bring the device back. As soon as the device shows online again, confirm its repository rows switch from the mark to the owner's avatar.
  • With a device online, take its network down. Confirm no ssh … git config processes linger for that host (ps), and that the avatars return once it reconnects.
  • Confirm local rows still show their avatars, and that a non-Git remote workspace shows no mark.
  • Confirm a remote workspace without worktree metadata keeps the GitHub mark.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PZZjmvxMrYtHtHTBju7zD8

Sidebar repository icons were resolved only for the local endpoint,
because the owner lookup ran `git config` in the workspace directory on
this machine. A remote device's rows always fell back to the generic
GitHub mark.

Avatar requests are now keyed by a `Checkout`: a local directory read by
local Git, or, for a saved SSH device, the Git directory its daemon
reported, read over the same bounded, noninteractive SSH config read the
pull request lookup already uses. Remote directories are still never
resolved against this machine's repositories, and other socket targets
get no lookup. Worktrees share their common Git directory, so one SSH
read covers every worktree of a repository on a host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@theontho
theontho requested a review from penso as a code owner October 20, 2026 05:03
@greptile-apps

greptile-apps Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; the latest changes address the remaining reconnect and deleted-device concerns.

Summary

The PR adds owner avatars for SSH workspaces and keeps their reads tied to device connections.

  • SSH workspace rows can show their repository owner's avatar.
  • SSH avatar lookups recover when saved devices reconnect.
  • Local avatar lookups no longer wait behind SSH reads.
  • Two timing-sensitive tests allow more room for slower machines.

Reviews (4) · Last reviewed commit: "fix: retry SSH avatars a reconnect misse..." · Reviewed by Greptile

Comment thread crates/herdr-gpui/src/avatars.rs Outdated
A failed SSH read of a remote checkout's Git configuration was cached
like a checkout without a GitHub owner, so a device that was briefly
unreachable when its first snapshot arrived kept the generic mark until
the app restarted.

The owner lookup now returns an error only when the host could not be
read. Such a checkout is marked unreachable with a retry time 60 seconds
later; the next sidebar snapshot update after that asks again. A host
read without a GitHub owner is still cached for good, and a retry reuses
its entry, so it never counts against the request bound as a new one.
cc727de stopped reserving an empty chevron column on rows that draw no
chevron, and updated the headless width checks, but the native paint
probe still subtracted ARROW_RESERVE from a group member's label. The
Windows native GUI job, the only CI job that runs it, then failed on
every branch with "child label width: actual 179px, expected 161px".
The headless checks of the native child probe built their painted labels at the old width, which still gave up the arrow column. Each now adds ARROW_RESERVE (18px) to match the probe's expectation.
@theontho

theontho commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

just ci ran locally and passed on 69c4acaa (macOS arm64).

theontho and others added 6 commits October 20, 2026 09:33
SSH owner reads shared the single lookup worker with local Git lookups.
A host that stopped answering after its workspace snapshot arrived held
that worker for up to the 10 s read budget per repository, so several
queued remote repositories could delay a newly opened local workspace's
avatar by about a minute, even when its image was already on disk.

Local lookups now have their own queue and worker, and SSH reads run on
a separate pool of four readers, so a single stalled host cannot block
other devices either. The 128-entry request bound still spans both.
relaunch_retries_then_alerts_when_open_keeps_failing failed twice in
local `just ci` runs at load average ~67: the script had logged "open
failed" but had not yet run the alert when the 10 s wait expired. That
path sleeps 3.25 s between about a dozen process spawns. The deadline
bounds only a failing wait, so passing runs are no slower.
resting_on_a_workspace_opens_its_menu_once polled at Instant::now() plus
half the 600 ms dwell and expected the menu to stay closed, but the
drift move had stamped its own Instant::now() earlier. At load average
~65, more than 300 ms passed between the two and the menu opened. The
early checks now poll at an instant taken before the move, which can
never reach the dwell.
Remote avatar reads kept their own 60 s retry timer, independent of the
window's connection to the device. A read could dial a host the window
already knew was down, and a device that came back waited for that
timer before its rows got avatars.

Avatars now follow each saved SSH endpoint's connection. While no
endpoint on a host is connected, nothing is requested for it, reads
queued for it never dial, and running reads are cancelled through the
SSH read's cancel hook. When a connection comes up with a snapshot,
the host's unreadable checkouts are due at once, so that first snapshot
asks for them again. A read that fails while the device stays connected
still waits 60 s. Another session's endpoint on the same host keeps the
host connected.
# Conflicts:
#	crates/herdr-gpui/src/sidebar/layout_tests.rs
@penso

penso commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

just ci ran locally and passed on 7858668 — macOS arm64.

@penso

penso commented Oct 21, 2026

Copy link
Copy Markdown
Owner

@greptileai review

Comment thread crates/herdr-gpui/src/avatars/links.rs
Comment thread crates/herdr-gpui/src/endpoint/polling.rs
…devices

A read still pending when its device reconnected was skipped by that
connection's snapshot, and a later failure left it waiting for another
endpoint update. Pending slots now record when they were asked, and a
failure that arrives after a newer connection asks again at once.

Devices removed from the catalog were never followed again, so their
link kept the host marked online and queued reads still dialed it. The
poll loop now drops links for endpoints that left the catalog.
@penso

penso commented Oct 21, 2026

Copy link
Copy Markdown
Owner

@greptileai review

The login shell timeout test gave the shell one second to start and record
its pids, which a busy machine missed. The closed-port test dropped its
listener, so a parallel test could be given the port or a probe could
connect to itself; an accepted connection now keeps the port in use.
@theontho

Copy link
Copy Markdown
Contributor Author

The only red check on 69c4acaa is Windows native GUI (windows-2025) (and the Format, lint, and test gate that depends on it). native_gui_live timed out at step 1: the GUI dispatched tab.create, but the daemon had not produced the new tab after 20 s. This head changes two unit tests compared with 78586689, where this job passed, and touches no tab or input code. I can't re-run jobs here; could a maintainer re-run the failed job?

This branch has not been deployed

No deployments
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.

2 participants