From 942ebfbc54b34642add92c2ad6e4e7a0f2e447ca Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 12 May 2026 15:25:54 +1000 Subject: [PATCH 1/3] feat: share fetch state across branches in the same local repo Add a repo-level fetch cache keyed by repo path so that when multiple branches share the same local .git directory, a single git fetch satisfies all of them within the TTL window. Implements "Option C" from the plan: the repo-level TTL gates the "did we recently talk to the remote?" decision, while per-branch refspec tracking ensures any new refspecs get a cheap narrow fetch (without --prune) when the repo is already fresh. Remote projects continue using per-branch caching unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/git/state.rs | 228 +++++++++++++++++++++---- 1 file changed, 198 insertions(+), 30 deletions(-) diff --git a/apps/staged/src-tauri/src/git/state.rs b/apps/staged/src-tauri/src/git/state.rs index a6e885a1..0a251810 100644 --- a/apps/staged/src-tauri/src/git/state.rs +++ b/apps/staged/src-tauri/src/git/state.rs @@ -2,7 +2,7 @@ use super::cli::{self, GitError}; use super::refs::{branch_name_without_origin, origin_ref_for_branch}; use super::status_parse::is_conflicted_status; use serde::Serialize; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -154,6 +154,35 @@ fn fetch_cache() -> &'static Mutex> { CACHE.get_or_init(|| Mutex::new(HashMap::new())) } +/// Repo-level fetch tracking for local projects. +/// +/// A single `git fetch` updates all remote refs for a repo, so when multiple +/// branches share the same local `.git` directory we only need to hit the +/// network once per TTL window. This cache is keyed by repo path and tracks +/// *which* refspecs were included in the last fetch so we can do a cheap +/// narrow fetch for any new refspecs that appear. +#[derive(Debug, Clone)] +struct RepoFetchEntry { + fetched_at: i64, + fetched_refspecs: HashSet, +} + +fn repo_fetch_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Extract the repo path portion from a local cache key (`local:{repo_path}:…`). +fn repo_key_from_local_cache_key(cache_key: &str) -> Option { + let rest = cache_key.strip_prefix("local:")?; + // The key format is `local:{repo_path}:{branch}:{base}`. + // repo_path may contain colons (e.g. Windows paths, though unlikely on + // macOS/Linux). We split from the right to peel off base and branch. + let (without_base, _base) = rest.rsplit_once(':')?; + let (repo_path, _branch) = without_base.rsplit_once(':')?; + Some(format!("local:{repo_path}")) +} + fn now_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -180,6 +209,47 @@ fn refspec_for(remote_branch: &str) -> String { format!("+refs/heads/{branch}:refs/remotes/origin/{branch}") } +/// Determine which refspecs still need fetching given repo-level cache state. +/// +/// Returns `None` if no fetch is needed at all (repo fresh + all refspecs covered). +/// Returns `Some(refspecs)` with the list of refspecs to fetch — either all of +/// them (repo stale) or just the missing ones (repo fresh, new refspecs). +fn refspecs_to_fetch( + repo_key: Option<&str>, + needed: &[&str], + fetch_mode: FetchMode, + now: i64, +) -> Option> { + match fetch_mode { + FetchMode::Never => None, + FetchMode::Force => Some(needed.iter().map(|s| s.to_string()).collect()), + FetchMode::Ttl => { + let repo_entry = repo_key.and_then(|key| { + repo_fetch_cache() + .lock() + .ok() + .and_then(|cache| cache.get(key).cloned()) + }); + match repo_entry { + Some(entry) if now.saturating_sub(entry.fetched_at) <= FETCH_TTL_MS => { + // Repo is fresh — only fetch refspecs not already covered. + let missing: Vec = needed + .iter() + .filter(|rs| !entry.fetched_refspecs.contains(**rs)) + .map(|s| s.to_string()) + .collect(); + if missing.is_empty() { + None + } else { + Some(missing) + } + } + _ => Some(needed.iter().map(|s| s.to_string()).collect()), + } + } + } +} + fn refresh_refs_if_needed( cache_key: &str, run_git: &F, @@ -196,15 +266,45 @@ where .ok() .and_then(|cache| cache.get(cache_key).cloned()); - let should_fetch = match (fetch_mode, &previous) { - (FetchMode::Never, _) => false, - (FetchMode::Force, _) => true, - (FetchMode::Ttl, Some(entry)) => now.saturating_sub(entry.fetched_at) > FETCH_TTL_MS, - (FetchMode::Ttl, None) => true, + let base_refspec = refspec_for(base_branch); + let branch_refspec = refspec_for(branch_name); + + // Build the list of needed refspecs (deduplicated). + let needed: Vec<&str> = if branch_refspec != base_refspec { + vec![base_refspec.as_str(), branch_refspec.as_str()] + } else { + vec![base_refspec.as_str()] + }; + + // For local keys, consult the repo-level cache to avoid redundant fetches + // when multiple branches share the same repo. + let repo_key = repo_key_from_local_cache_key(cache_key); + let to_fetch = refspecs_to_fetch(repo_key.as_deref(), &needed, fetch_mode, now); + + // Also check the per-branch cache for the legacy should_fetch decision + // (used when there's no repo key, i.e. remote branches). + let should_fetch = if repo_key.is_some() { + to_fetch.is_some() + } else { + match (fetch_mode, &previous) { + (FetchMode::Never, _) => false, + (FetchMode::Force, _) => true, + (FetchMode::Ttl, Some(entry)) => now.saturating_sub(entry.fetched_at) > FETCH_TTL_MS, + (FetchMode::Ttl, None) => true, + } }; if !should_fetch { - let fetched_at = previous.as_ref().map(|entry| entry.fetched_at); + let fetched_at = previous.as_ref().map(|entry| entry.fetched_at).or_else(|| { + // For local keys the repo-level cache may have a timestamp even + // if the per-branch cache doesn't yet. + repo_key.as_deref().and_then(|key| { + repo_fetch_cache() + .lock() + .ok() + .and_then(|cache| cache.get(key).map(|e| e.fetched_at)) + }) + }); return RefreshOutcome { fetch: FetchGitState { status: if fetched_at.is_some() { @@ -222,25 +322,36 @@ where }; } - let base_refspec = refspec_for(base_branch); - let branch_refspec = refspec_for(branch_name); + // Determine refspecs to actually send over the wire. + let fetch_refspecs: Vec = to_fetch.unwrap_or_else(|| { + // Fallback for remote keys (no repo-level cache) — fetch all needed. + needed.iter().map(|s| s.to_string()).collect() + }); + + // Is this a narrow supplemental fetch (repo fresh, just filling in gaps)? + let is_narrow = repo_key.is_some() && fetch_refspecs.len() < needed.len(); + let mut upstream_known_missing = false; - // Fetch both refspecs in a single network call when they differ. - if branch_refspec != base_refspec { - match run_git(&[ - "fetch", - "--prune", - "origin", - base_refspec.as_str(), - branch_refspec.as_str(), - ]) { + if fetch_refspecs.len() > 1 { + let refs: Vec<&str> = fetch_refspecs.iter().map(String::as_str).collect(); + let mut args = vec!["fetch"]; + if !is_narrow { + args.push("--prune"); + } + args.push("origin"); + args.extend(refs.iter()); + match run_git(&args) { Err(error) if is_missing_remote_ref(&error) => { // The branch refspec is missing on the remote. Re-fetch with // just the base refspec so we still get base branch updates. - if let Err(base_err) = - run_git(&["fetch", "--prune", "origin", base_refspec.as_str()]) - { + let mut retry_args = vec!["fetch"]; + if !is_narrow { + retry_args.push("--prune"); + } + retry_args.push("origin"); + retry_args.push(base_refspec.as_str()); + if let Err(base_err) = run_git(&retry_args) { return RefreshOutcome { fetch: FetchGitState { status: FetchStatus::Failed, @@ -264,17 +375,34 @@ where } Ok(_) => {} } - } else if let Err(error) = run_git(&["fetch", "--prune", "origin", base_refspec.as_str()]) { - return RefreshOutcome { - fetch: FetchGitState { - status: FetchStatus::Failed, - fetched_at: previous.map(|entry| entry.fetched_at), - error: Some(error.trim().to_string()), - }, - upstream_known_missing: false, - }; + } else { + let refspec = fetch_refspecs + .first() + .map(String::as_str) + .unwrap_or(base_refspec.as_str()); + let mut args = vec!["fetch"]; + if !is_narrow { + args.push("--prune"); + } + args.push("origin"); + args.push(refspec); + if let Err(error) = run_git(&args) { + if is_missing_remote_ref(&error) { + upstream_known_missing = true; + } else { + return RefreshOutcome { + fetch: FetchGitState { + status: FetchStatus::Failed, + fetched_at: previous.map(|entry| entry.fetched_at), + error: Some(error.trim().to_string()), + }, + upstream_known_missing: false, + }; + } + } } + // Update per-branch cache. if let Ok(mut cache) = fetch_cache().lock() { cache.insert( cache_key.to_string(), @@ -285,6 +413,20 @@ where ); } + // Update repo-level cache for local keys. + if let Some(ref rk) = repo_key { + if let Ok(mut cache) = repo_fetch_cache().lock() { + let entry = cache.entry(rk.clone()).or_insert_with(|| RepoFetchEntry { + fetched_at: now, + fetched_refspecs: HashSet::new(), + }); + entry.fetched_at = now; + for rs in &needed { + entry.fetched_refspecs.insert(rs.to_string()); + } + } + } + RefreshOutcome { fetch: FetchGitState { status: FetchStatus::Fresh, @@ -521,8 +663,34 @@ pub fn compute_local_branch_git_state( /// Check whether a fetch is needed for the given cache key and mode. /// Used by timeline to decide whether to use the two-stream path. +/// +/// For local keys this consults the repo-level cache so the decision is +/// consistent with `refresh_refs_if_needed` — if another branch on the same +/// repo recently fetched, this returns `false`. pub fn needs_fetch(cache_key: &str, fetch_mode: FetchMode) -> bool { let now = now_ms(); + + // For local keys, check the repo-level cache first. + if let Some(repo_key) = repo_key_from_local_cache_key(cache_key) { + return match fetch_mode { + FetchMode::Never => false, + FetchMode::Force => true, + FetchMode::Ttl => { + let repo_fresh = repo_fetch_cache() + .lock() + .ok() + .and_then(|cache| cache.get(&repo_key).cloned()) + .map(|entry| now.saturating_sub(entry.fetched_at) <= FETCH_TTL_MS) + .unwrap_or(false); + // Even if the repo is fresh, we might still need a narrow + // fetch for uncovered refspecs — but that's fast enough that + // we don't need the two-stream split for it. + !repo_fresh + } + }; + } + + // Remote / non-local keys: fall back to per-branch cache. let previous = fetch_cache() .lock() .ok() From 5d4e777037682c70755d47ac4ab89aa39da640fd Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 12 May 2026 15:36:17 +1000 Subject: [PATCH 2/3] test: add unit tests for repo_key_from_local_cache_key parsing Cover normal Unix path, Windows-style path with colons, branch==base edge case, non-local prefix rejection, and too-few-segments rejection to lock in the double rsplit_once parsing invariant. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/git/state.rs | 36 +++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/staged/src-tauri/src/git/state.rs b/apps/staged/src-tauri/src/git/state.rs index 0a251810..4a01d1f6 100644 --- a/apps/staged/src-tauri/src/git/state.rs +++ b/apps/staged/src-tauri/src/git/state.rs @@ -1404,7 +1404,7 @@ pub fn ensure_fast_forward_pullable(state: &BranchGitState) -> Result<(), String mod tests { use super::*; - fn assert_worktree( +fn assert_worktree( input: &str, dirty: bool, modified: u32, @@ -1563,4 +1563,38 @@ mod tests { assert!(!is_conflicted_status('R', ' ')); assert!(!is_conflicted_status('?', '?')); } + + #[test] + fn repo_key_from_local_cache_key_normal_path() { + assert_eq!( + repo_key_from_local_cache_key("local:/Users/me/project:feature:main"), + Some("local:/Users/me/project".to_string()), + ); + } + + #[test] + fn repo_key_from_local_cache_key_windows_path() { + assert_eq!( + repo_key_from_local_cache_key("local:C:\\Users\\me\\project:feature:main"), + Some("local:C:\\Users\\me\\project".to_string()), + ); + } + + #[test] + fn repo_key_from_local_cache_key_branch_equals_base() { + assert_eq!( + repo_key_from_local_cache_key("local:/repo:main:main"), + Some("local:/repo".to_string()), + ); + } + + #[test] + fn repo_key_from_local_cache_key_not_local() { + assert_eq!(repo_key_from_local_cache_key("remote:foo:bar:baz"), None); + } + + #[test] + fn repo_key_from_local_cache_key_too_few_segments() { + assert_eq!(repo_key_from_local_cache_key("local:only_one"), None); + } } From aa9d89e59b7184c1b529e2a806e731de6cd5bf4c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 12 May 2026 16:42:25 +1000 Subject: [PATCH 3/3] fix: reset fetched_refspecs on full fetch instead of extending On a full fetch (TTL expired, --prune), replace the repo-level fetched_refspecs set with exactly the needed refspecs rather than extending the existing set. This prevents unbounded growth from branches that were opened and later closed. Narrow fetches (within TTL, filling gaps) continue to extend additively as before. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/git/state.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/staged/src-tauri/src/git/state.rs b/apps/staged/src-tauri/src/git/state.rs index 4a01d1f6..3b90fe1a 100644 --- a/apps/staged/src-tauri/src/git/state.rs +++ b/apps/staged/src-tauri/src/git/state.rs @@ -421,8 +421,12 @@ where fetched_refspecs: HashSet::new(), }); entry.fetched_at = now; - for rs in &needed { - entry.fetched_refspecs.insert(rs.to_string()); + if is_narrow { + for rs in &fetch_refspecs { + entry.fetched_refspecs.insert(rs.clone()); + } + } else { + entry.fetched_refspecs = needed.iter().map(|s| s.to_string()).collect(); } } } @@ -1404,7 +1408,7 @@ pub fn ensure_fast_forward_pullable(state: &BranchGitState) -> Result<(), String mod tests { use super::*; -fn assert_worktree( + fn assert_worktree( input: &str, dirty: bool, modified: u32,