24 KiB
Watch remote refs for updates — Tech Spec
Product spec: specs/GH10090/product.md
GitHub issue: https://github.com/warpdotdev/warp/issues/10090
Problem
repo_metadata already watches repository roots and selected Git internals, but remote-tracking refs under .git/refs/remotes/* are filtered out. Code review metadata computes unpushed commits from the current branch's upstream ref, so a push or fetch that updates a loose remote-tracking ref can leave DiffStateModel and the Git operations UI stale until another invalidation happens.
The implementation needs to allow loose remote-ref watcher events through, keep each watched Repository aware of the loose remote ref tracked by its active branch, refresh that cached tracking state when HEAD or Git config changes can alter it, and expose remote-ref invalidations explicitly on RepositoryUpdate.
Relevant code
crates/repo_metadata/src/entry.rs (361-484)— Git internal path helpers:git_suffix_components,extract_worktree_git_dir,is_shared_git_ref,is_commit_related_git_file,is_index_lock_file, andshould_ignore_git_path.crates/repo_metadata/src/entry_test.rs (222-395)— current allowlist and shared-ref tests; remote refs and.git/configare currently asserted as ignored.crates/repo_metadata/src/watcher.rs (120-194)—DirectoryWatcher::find_repos_for_git_event, which routes worktree-specific, shared local branch ref, and repo-specific Git events.crates/repo_metadata/src/watcher.rs (293-335)—start_watching_directory, which registers watched paths with theshould_ignore_git_pathfilter.crates/repo_metadata/src/watcher.rs (391-529)— filesystem event handling that converts Git internal events intoRepositoryUpdate.crates/repo_metadata/src/watcher.rs (588-626)—RepositoryUpdate, especiallycommit_updatedandindex_lock_detected.crates/repo_metadata/src/repository.rs (54-151)—Repositorystoresroot_dir, optional per-worktreeexternal_git_directory, and optional sharedcommon_git_directory.crates/repo_metadata/src/repository.rs (162-229)—Repository::start_watching, which registers the worktree root, per-worktree gitdir, and sharedrefs/headsfor linked worktrees.app/src/code_review/diff_state.rs (1116-1247)— code review repository subscriber maps repository updates to metadata invalidation and throttled metadata refresh.app/src/code_review/diff_state.rs (1347-1393)—load_metadata_for_reporeads@{u}and recomputesunpushed_commits.app/src/code_review/git_status_update.rs (168-283)— Git status metadata watcher refreshes when repository metadata flags indicate Git state changed.app/src/util/git.rs (391-468)—get_unpushed_commitscomputes<upstream>..HEAD.
Current state
should_ignore_git_path uses an allowlist for .git internals. Only commit-related files (HEAD and refs/heads/*) and index.lock are allowed through; .git/refs/remotes/origin/main and .git/config are explicitly ignored in entry_test.rs.
For normal repositories, the repository root watcher is recursive, so .git/refs/remotes/*, .git/config, and .git/HEAD would be observable if the filter allowed them. For linked worktrees, remote refs and shared config are stored in the shared common .git directory, outside the worktree checkout and outside the per-worktree gitdir. Repository::start_watching currently adds common_git_dir/refs/heads for shared local branch refs, but it does not add common_git_dir/refs/remotes or common Git config.
Once a Git internal path reaches DirectoryWatcher::handle_watcher_event, it is classified with is_commit_related_git_file or is_index_lock_file. A commit_updated update is enough to trigger the existing code review and git-status metadata refresh paths, but it does not distinguish a local commit/branch update from an upstream remote-ref update. The missing pieces are path classification, cached tracked-upstream state on Repository, watcher registration for linked worktrees, scope-aware routing to repositories tracking the changed remote ref, and an explicit RepositoryUpdate field for remote-ref changes.
Proposed changes
1. Add remote-tracking ref and tracking-config path helpers
Extend crates/repo_metadata/src/entry.rs with helpers for loose remote-tracking refs and Git files that can change a repository's tracked remote ref:
is_remote_tracking_ref(path: &Path) -> bool- true for paths under
.git/refs/remotes/<remote>/<branch...>. - false for paths under
.git/worktrees/<name>/...,.git/refs/heads/*,.git/refs/tags/*,.git/packed-refs, and non-Git paths. - requires at least a remote component and one branch component after
refs/remotes.
- true for paths under
remote_tracking_ref_path_under_common_git_dir(path: &Path) -> Option<PathBuf>- canonicalization-friendly helper for routing. It returns the full loose ref path only for shared remote refs, not worktree-local files.
is_tracking_state_git_file(path: &Path) -> bool- true for files that can change the active branch's tracked remote ref: repository-specific
HEAD, common.git/config, and per-worktreeconfig.worktreewhen present. - false for local branch ref files, tags,
packed-refs, objects, logs, and hooks.
- true for files that can change the active branch's tracked remote ref: repository-specific
Update should_ignore_git_path so loose remote-tracking refs and tracking-state files are allowlisted. Keep packed-refs ignored. Do not add broad refs/* matching; tags and other Git internals remain filtered out.
Keep is_commit_related_git_file focused on .git/HEAD and .git/refs/heads/*. Remote refs should not be folded into that helper because RepositoryUpdate will expose them separately.
2. Store the tracked remote ref on Repository
Add a cached tracked-upstream field to crates/repo_metadata/src/repository.rs:
tracked_remote_ref: Option<TrackedRemoteRef>
Add a small internal type:
TrackedRemoteReffull_ref_name: String
full_ref_name should be the symbolic full upstream ref returned by Git, for example refs/remotes/origin/feature. Store the ref name rather than remote/branch components so Git owns branch config parsing, quoted branch names, worktree config, includes, and other config edge cases.
Repository should initialize this field in Repository::new or during the first watcher registration, and expose narrow helpers:
pub(crate) fn tracked_remote_ref(&self) -> Option<&TrackedRemoteRef>pub(crate) fn tracks_remote_ref_path(&self, remote_ref_path: &Path) -> boolpub(crate) fn refresh_tracked_remote_ref(&mut self) -> boolpub(crate) fn tracked_remote_ref_path(&self) -> Option<PathBuf>
refresh_tracked_remote_ref should run Git in the repository worktree context and update the cached value:
- Run
git -C <repo_root> rev-parse --symbolic-full-name @{u}. - If Git exits non-zero, cache
None. This covers detachedHEAD, no upstream, malformed config, unreadable config, and racing branch changes. - Trim stdout to one line.
- If the ref name does not start with
refs/remotes/, cacheNone. This excludes local upstreams such asremote = .that resolve torefs/heads/<branch>. - Validate the ref name is relative and does not contain path traversal components.
- Cache
TrackedRemoteRef { full_ref_name }.
Implementation details:
- Prefer using an existing Git command helper in the app/repo metadata layer if one is available; otherwise add a narrow helper dedicated to resolving the current upstream ref.
- Do not run Git for every remote-ref event. Run it only on repository construction/startup and allowlisted tracking-state events (
HEAD, common.git/config, and optionalconfig.worktree). Remote-ref routing should use the cached value. - Use the existing repository task queue for Git-backed upstream refreshes so filesystem watcher routing does not block on process execution. Add a task such as
Task::RefreshTrackedRemoteRef { repository: WeakModelHandle<Repository> }. The task should run the Git command off the watcher event path, then update the repository cache on completion. If the cached value changed, enqueueRepositoryUpdate { remote_ref_updated: true, ..Default::default() }for that repository's subscribers. Do not broaden remote-ref routing to every repository as a shortcut. tracked_remote_ref_pathshould computeself.common_git_dir().join(full_ref_name)from the cachedTrackedRemoteRef; do not store the path separately as duplicate state.tracks_remote_ref_pathshould compare the changed path to the computed tracked remote ref path, normalizing/canonicalizing existing parent paths where possible.
This cached field is the scope boundary that satisfies the product requirement that only repositories tracking the changed remote ref refresh metadata.
3. Refresh cached tracking state when it can change
The tracked remote ref for a watched repository can change when any of these local files change:
self.git_dir()/HEAD: the active branch changes,HEADbecomes detached, orHEADis reattached to a branch with a different upstream.self.common_git_dir()/config: branch upstream config changes, includinggit branch --set-upstream-to,git branch --unset-upstream,git push -u,git remote rename, and manual edits tobranch.<name>.remoteorbranch.<name>.merge.self.git_dir()/config.worktree: worktree-specific upstream config changes, if worktree-specific config is enabled and this repo supports reading it.
Update watcher handling so events for is_tracking_state_git_file(path) enqueue a tracked-remote-ref refresh task for each affected repository instead of running Git inline. When the queued task completes, it should compare the resolved upstream ref to the cached value. If refresh_tracked_remote_ref changed the cache, enqueue RepositoryUpdate { remote_ref_updated: true, ..Default::default() } for that repository.
Routing for tracking-state files should be scoped by where the file lives:
- Worktree-specific
HEADandconfig.worktreeunder.git/worktrees/<name>/...route only to that linked worktree. - Normal repo
.git/HEADroutes only to the repository whose working tree owns that.gitdirectory. - Common
.git/configroutes to all watched repositories whosecommon_git_dir()matches that.gitdirectory, then each repository decides whether its cachedtracked_remote_refchanged.
A common config edit may affect multiple watched worktrees if Git resolves a different upstream for their active branches. It may also affect none of them. The config event may enqueue refresh tasks for every watched repository sharing that common Git directory, but only repositories whose resolved upstream changed should receive remote_ref_updated. Do not broaden remote-ref refreshes to every watched worktree as a substitute for the per-repository cache check.
4. Register shared Git paths for linked worktrees
Update Repository::start_watching in crates/repo_metadata/src/repository.rs so linked worktrees watch shared Git paths from the common Git directory in a way that includes remote-tracking refs and shared config.
Preferred registration for linked worktrees:
- the worktree root, as today.
- the per-worktree gitdir, as today, for
HEAD,index.lock, and optionalconfig.worktree. common_git_dir/refs, when it exists, so shared local branch refs and remote-tracking refs are visible.common_git_dir/config, if the watcher can register a file path; otherwisecommon_git_dirwith the existing allowlist filter.
This lets a linked worktree observe both existing remote refs and first-time creation under refs/remotes without having to create Git directories from Warp. It also lets upstream tracking additions/removals in common config update the cached tracked_remote_ref.
Update Repository::stop_watching to unregister the same shared paths when the last subscriber is removed. Keep start/stop symmetric, preferably by sharing a helper that computes the optional watch paths.
For normal repositories, no extra watch path is required because the root watcher already recursively covers .git/refs/remotes, .git/config, and .git/HEAD once the filter allows those paths.
5. Add remote-ref routing in DirectoryWatcher
Update DirectoryWatcher::find_repos_for_git_event in crates/repo_metadata/src/watcher.rs with a routing tier for shared remote-tracking refs:
- Worktree-specific paths under
.git/worktrees/<name>/...keep the existing highest-priority route. - Remote-tracking refs under
.git/refs/remotes/*route to watched repositories whosecommon_git_dir()contains the event path and whoseRepository::tracks_remote_ref_path(event_path)returns true based on the cached full upstream ref name. - Shared local branch refs under
.git/refs/heads/*keep the existing broadcast-to-shared-common-git-dir route. - Common
.git/configroutes to all watched repositories sharing that common Git directory so each can refresh its cached tracked remote ref. - Repo-specific paths keep the existing fallback route.
The remote-ref tier should deduplicate repository handles just like the existing tiers. It should log the routing tier and affected repo count with the existing [GIT_EVENT_ROUTING] pattern.
Remote-ref filesystem events should produce RepositoryUpdate { remote_ref_updated: true, ... } synchronously after cached-path routing. Tracking-state events should not produce an immediate subscriber update; they enqueue Task::RefreshTrackedRemoteRef, and task completion produces remote_ref_updated = true only for repositories whose cached tracked_remote_ref changed. No Git internal path should be added to added, modified, deleted, or moved, matching the current treatment for local Git metadata files.
6. Add a queued tracked-ref refresh task
Extend TaskQueue in crates/repo_metadata/src/watcher.rs with a task dedicated to refreshing the cached upstream ref:
Task::RefreshTrackedRemoteRef { repository: WeakModelHandle<Repository> }
The task should:
- Upgrade the repository handle.
- Run
refresh_tracked_remote_reffor that repository, using Git to resolve@{u}outside the watcher event path. - If the cached value changed, collect the repository's current subscriber IDs and enqueue
Task::UpdatewithRepositoryUpdate { remote_ref_updated: true, ..Default::default() }for each subscriber. - If the repository was dropped, Git fails, or the resolved upstream is unchanged, complete without delivering a subscriber update unless the cache changed to or from
None.
This preserves ordering enough for correctness: if git push -u updates both .git/config and a remote ref and the remote-ref event arrives before the cache refresh completes, that remote-ref event may not match the old cache. The queued config refresh still emits remote_ref_updated when the tracked upstream changes, so code review metadata refreshes without requiring broad remote-ref routing.
7. Add RepositoryUpdate.remote_ref_updated
Extend crates/repo_metadata/src/watcher.rs:
pub remote_ref_updated: bool- true when the repository's tracked upstream state changed, or when the loose remote-tracking ref currently tracked by the repository changed.
Update all RepositoryUpdate plumbing:
RepositoryUpdate::is_emptyshould include!self.remote_ref_updated.merge_repository_updatesshould ORremote_ref_updated, likecommit_updatedandindex_lock_detected.- Destructuring call sites, tests, and default builders should include the new field.
- Logs should distinguish
commit_updated,remote_ref_updated, andindex_lock_detected.
Keep commit_updated for local commit/branch state (HEAD and refs/heads/*). Use remote_ref_updated for upstream state so consumers can reason about refresh causes without conflating local and remote ref changes.
8. Preserve code review and git status behavior
Consumers should refresh metadata when either commit_updated or remote_ref_updated is true.
Existing flow after the watcher update:
Repositorycaches that the active branch tracks.git/refs/remotes/origin/feature.- Watcher sees
.git/refs/remotes/origin/featurechange. find_repos_for_git_eventroutes only to repositories whose cached full upstream ref name computes to that tracked remote ref path.handle_watcher_eventenqueuesRepositoryUpdate { remote_ref_updated: true, ... }.DiffStateModel::handle_file_updatetreatsremote_ref_updatedlikecommit_updatedfor metadata invalidation and schedules the throttled metadata refresh.load_metadata_for_repore-reads@{u}and recomputesget_unpushed_commits.CodeReviewViewobserves the updated metadata and recalculates the primary Git action.
Tracking-add/remove flow:
- Git updates
.git/configaftergit push -u,git branch --set-upstream-to, orgit branch --unset-upstream. - The watcher routes the config event to repositories sharing that common Git directory.
- Each repository calls
refresh_tracked_remote_ref. - Repositories whose cached tracked remote ref changed receive
RepositoryUpdate { remote_ref_updated: true, ... }. - Code review and Git status metadata refresh from the same path used for remote-ref content updates.
GitRepoStatusModel::should_refresh_metadata should also return true for remote_ref_updated, so branch/status metadata remains consistent.
9. Tests
Add and update unit tests in crates/repo_metadata:
entry_test.rsshould_ignore_git_pathdoes not ignore.git/refs/remotes/origin/main.should_ignore_git_pathdoes not ignore.git/configor.git/worktrees/<name>/config.worktree.- remote branch names with slashes are recognized.
.git/refs/remotes/originwithout a branch is not recognized..git/packed-refs,.git/refs/tags/*,.git/refs/heads/*, and worktree-local paths are not remote-tracking refs.- existing local branch and index-lock assertions remain unchanged.
repository.rstests or a newrepository_tests.rstracked_remote_refinitializes from a mocked or fixture-backedgit rev-parse --symbolic-full-name @{u}result such asrefs/remotes/origin/feature.tracked_remote_ref_pathcomputescommon_git_dir/refs/remotes/origin/featurefrom cached full ref name state.tracks_remote_ref_pathreturns true for the computed loose remote ref path.- slash-containing branch names are preserved in the full ref name returned by Git.
refresh_tracked_remote_refreturns true when Git reports tracking was added, removed, or changed to a different upstream ref, and false when the resolved upstream ref is unchanged.- returns/caches
Nonefor detachedHEAD, no upstream, Git command failure, local upstreams such asrefs/heads/main, malformed output, absolute paths, and path traversal components. - runs Git in the worktree root so linked worktrees resolve their own active branch and worktree-specific config correctly.
watcher_tests.rs- a matching remote-ref event is delivered to a subscriber as
remote_ref_updated = true,commit_updated = false, and contains no file-list changes. - an unrelated remote-ref event is not delivered to a repository that tracks a different upstream.
- two repositories/worktrees tracking the same remote ref both receive the update.
- a
.git/configchange that adds, removes, or changes the active branch upstream enqueuesTask::RefreshTrackedRemoteRef, and task completion emitsremote_ref_updated = true. - a
.git/configchange unrelated to the active branch may enqueueTask::RefreshTrackedRemoteRef, but task completion does not emitremote_ref_updatedwhen the resolved upstream is unchanged. - linked worktrees register/unregister shared refs and shared config in a way that covers
refs/remotes, including first-time creation underrefs/remoteswhen the commonrefsdirectory exists. - local
refs/heads/*, worktree-specificHEAD, andindex.lockrouting continue to pass existing regression tests.
- a matching remote-ref event is delivered to a subscriber as
Update app-level tests only if repo-metadata tests cannot prove the end-to-end invalidation contract. The key app-level assertion is that DiffStateModel::handle_file_update treats remote_ref_updated as full metadata invalidation, matching commit_updated.
10. Manual validation
- Open Warp code review on a branch tracking
origin/<branch>with one or more unpushed commits. - Push the branch from Warp or an external terminal.
- Confirm the loose ref
.git/refs/remotes/origin/<branch>updates. - Confirm the unpushed commit list clears and the primary Git action updates without reopening code review.
- Run
git branch --unset-upstream, thengit branch --set-upstream-to=origin/<branch>, and confirm metadata refreshes when tracked remote ref state is removed and restored. - Repeat with another branch's remote ref update and confirm the active branch does not refresh.
- Repeat in a linked worktree whose common
.gitdirectory is outside the worktree checkout.
Risks and mitigations
Risk: over-invalidating every worktree sharing a common .git
Shared refs and shared config are visible to every linked worktree. Broadcasting remote-ref changes to all watched worktrees would satisfy freshness but violate the product requirement and create unnecessary metadata work.
Mitigation: cache only Git's resolved full upstream ref name on each Repository and compute the loose remote ref path from common_git_dir() when routing. For common config changes, deliver updates only when refresh_tracked_remote_ref changes the cached value. Tests must include two worktrees sharing the same common .git directory but tracking different upstream refs.
Risk: cached tracked remote ref becomes stale
If the watcher misses a HEAD or config event, remote-ref routing could use an outdated cached upstream ref name.
Mitigation: initialize the cache in Repository::new, refresh it on all allowlisted tracking-state events, and refresh it during initial scan/start-watching if needed. Keep existing manual and metadata refresh paths as backstops. Prefer conservative false negatives over routing unrelated remote refs to every repository.
Risk: running Git from watcher-triggered state refreshes
Resolving the upstream with Git is more correct than parsing config, but it introduces process execution when HEAD or config changes.
Mitigation: run Git only on repository construction/startup and tracking-state events, not on every remote-ref event. Use Task::RefreshTrackedRemoteRef in the existing repository task queue so watcher routing never blocks on Git. Cache None on Git failures and let existing manual/other refresh paths handle the repository.
Risk: over-broad shared Git watching
Watching common_git_dir/refs or common_git_dir for linked worktrees is broader than the current refs/heads registration.
Mitigation: keep should_ignore_git_path as the allowlist boundary. Only local branch refs, remote-tracking refs, tracking-state files, and index/HEAD files should pass through; tags and other refs remain ignored. Add tests proving tag changes and unrelated Git internals under the broader watched directory do not produce repository updates.
Risk: stale reads while Git is updating refs or config
A filesystem event can fire while Git is still writing a ref or replacing config.
Mitigation: route based on path and cached tracking state, refresh cached tracking state after config/HEAD events by asking Git for the current upstream, then reuse the existing debounced watcher and throttled metadata refresh. If metadata load races and observes stale state, later Git file events or manual refresh paths should correct it. Avoid adding bespoke retry loops unless tests prove they are necessary.
Follow-ups
- Support
.git/packed-refsif product later needs packed remote refs to trigger metadata refresh. - Consider a shared utility for resolving the current branch's upstream ref if other Git UI features need the same information.
- Consider adding telemetry around metadata refresh causes if remote-ref invalidations become performance-sensitive.