diff options
| author | JJ Liebig <jonathan.liebig@gmail.com> | 2026-08-12 03:14:59 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-12 04:14:59 +0300 |
| commit | 5600197f00e871764465d4e3d9ba5e6aa6fd9547 (patch) | |
| tree | 26fd7ddb5f784f39aa4bec98735c316331cef211 /src | |
| parent | 3f752a72fdd073d875cf941e3049f9860e30d841 (diff) | |
perf: avoid repeated Git config reads (#2688)
refs #2643
Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/git_refresh.rs | 18 | ||||
| -rw-r--r-- | src/workspace/git/config.rs | 117 | ||||
| -rw-r--r-- | src/workspace/git/config_tests.rs | 55 | ||||
| -rw-r--r-- | src/workspace/git/status.rs | 139 |
4 files changed, 270 insertions, 59 deletions
diff --git a/src/app/git_refresh.rs b/src/app/git_refresh.rs index a868a0ed..348b1556 100644 --- a/src/app/git_refresh.rs +++ b/src/app/git_refresh.rs @@ -141,6 +141,7 @@ fn deduplicate_git_refresh_items( let mut jobs = Vec::<WorkspaceGitRefreshJob>::new(); for item in items { + let reconcile = item.cache_key_hint.is_none(); let cache_key = item.cache_key_hint.unwrap_or_else(|| { crate::workspace::git_status_cache_key(&item.resolved_identity_cwd) .unwrap_or_else(|| item.resolved_identity_cwd.clone()) @@ -150,11 +151,12 @@ fn deduplicate_git_refresh_items( resolved_identity_cwd: item.resolved_identity_cwd, }; if let Some(&index) = indexes.get(&cache_key) { + jobs[index].cached = jobs[index].cached.take().filter(|_| !reconcile); jobs[index].targets.push(target); continue; } - let cached = cache.get(&cache_key).cloned(); + let cached = cache.get(&cache_key).filter(|_| !reconcile).cloned(); indexes.insert(cache_key.clone(), jobs.len()); jobs.push(WorkspaceGitRefreshJob { cache_key, @@ -299,6 +301,20 @@ mod tests { assert_eq!(items.len(), 1); assert_eq!(items[0].cache_key_hint, None); + let cache_key = items[0].resolved_identity_cwd.clone(); + let cached = GitStatusCacheEntry { + fingerprint: None, + retry_after: None, + snapshot: crate::workspace::WorkspaceGitStatusSnapshot { + auto_label: "stale".into(), + branch: None, + ahead_behind: None, + space: None, + }, + }; + let jobs = deduplicate_git_refresh_items(items, &HashMap::from([(cache_key, cached)])); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].cached, None); } #[test] diff --git a/src/workspace/git/config.rs b/src/workspace/git/config.rs index 9783387c..9e386096 100644 --- a/src/workspace/git/config.rs +++ b/src/workspace/git/config.rs @@ -1,4 +1,7 @@ +use std::collections::HashMap; +use std::io::ErrorKind; use std::path::{Path, PathBuf}; +use std::time::SystemTime; use super::discovery::{canonicalize_best_effort_path, GitWorktreeInfo}; @@ -10,17 +13,68 @@ pub(super) struct BranchConfig { remote_urls: Vec<(String, String)>, } -pub(super) fn read_branch_config(info: &GitWorktreeInfo, branch: &str) -> Option<BranchConfig> { - read_branch_config_with_user_paths(info, branch, git_user_config_paths()) +type FileStamp = Option<(Option<SystemTime>, u64)>; +pub(super) type FileDep = (PathBuf, FileStamp, bool, Option<PathBuf>); + +pub(super) fn stamp(path: PathBuf, target: Option<PathBuf>) -> FileDep { + let meta = std::fs::metadata(&path); + let reusable = meta.is_ok() || matches!(&meta, Err(e) if e.kind() == ErrorKind::NotFound); + let stamp = meta.ok().map(|m| (m.modified().ok(), m.len())); + (path, stamp, reusable, target) +} + +pub(super) fn deps_current(deps: &[FileDep]) -> bool { + deps.iter().all(|dep| { + let target = dep + .3 + .as_ref() + .map(|_| canonicalize_best_effort_path(&dep.0)); + dep.2 && stamp(dep.0.clone(), target) == *dep + }) +} + +pub(super) type ConfigCtx = (String, Option<BranchConfig>, Vec<FileDep>); + +#[derive(Default)] +struct ConfigReader { + files: HashMap<PathBuf, Option<String>>, + deps: Vec<FileDep>, +} + +impl ConfigReader { + fn read(&mut self, path: &Path) -> (PathBuf, Option<String>) { + let logical = path.to_path_buf(); + let path = canonicalize_best_effort_path(path); + self.deps + .extend((logical != path).then(|| stamp(logical, Some(path.clone())))); + let deps = &mut self.deps; + let contents = self + .files + .entry(path.clone()) + .or_insert_with(|| { + let mut dep = stamp(path.clone(), None); + let r = std::fs::read_to_string(&path); + dep.2 &= r.is_ok() || matches!(&r, Err(e) if e.kind() == ErrorKind::NotFound); + deps.push(dep); + r.ok() + }) + .clone(); + (path, contents) + } +} + +pub(super) fn read_config(info: &GitWorktreeInfo, branch: &str) -> ConfigCtx { + read_config_with_user_paths(info, branch, git_user_config_paths()) } -pub(super) fn read_branch_config_with_user_paths( +pub(super) fn read_config_with_user_paths( info: &GitWorktreeInfo, branch: &str, user_config_paths: Vec<PathBuf>, -) -> Option<BranchConfig> { +) -> ConfigCtx { + let mut reader = ConfigReader::default(); let worktree_config_enabled = - worktree_config_enabled(&info.git_common_dir.join("config"), info); + worktree_config_enabled(&info.git_common_dir.join("config"), info, &mut reader); let config_paths = user_config_paths .into_iter() .chain(std::iter::once(info.git_common_dir.join("config"))) @@ -28,7 +82,14 @@ pub(super) fn read_branch_config_with_user_paths( let mut remote_urls = Vec::new(); for path in &config_paths { let mut include_stack = Vec::new(); - collect_remote_urls(path, info, branch, &mut remote_urls, &mut include_stack); + collect_remote_urls( + path, + info, + branch, + &mut remote_urls, + &mut include_stack, + &mut reader, + ); } let mut config = BranchConfig { remote: String::new(), @@ -38,7 +99,15 @@ pub(super) fn read_branch_config_with_user_paths( }; for path in config_paths { let mut include_stack = Vec::new(); - merge_git_config(&mut config, &path, branch, info, true, &mut include_stack); + merge_git_config( + &mut config, + &path, + branch, + info, + true, + &mut include_stack, + &mut reader, + ); } if worktree_config_enabled { let mut include_stack = Vec::new(); @@ -49,9 +118,14 @@ pub(super) fn read_branch_config_with_user_paths( info, false, &mut include_stack, + &mut reader, ); } - (!config.remote.is_empty() && !config.merge_ref.is_empty()).then_some(config) + ( + branch.to_string(), + (!config.remote.is_empty() && !config.merge_ref.is_empty()).then_some(config), + reader.deps, + ) } fn git_user_config_paths() -> Vec<PathBuf> { @@ -67,8 +141,8 @@ fn git_user_config_paths() -> Vec<PathBuf> { paths } -fn worktree_config_enabled(path: &Path, info: &GitWorktreeInfo) -> bool { - let Ok(contents) = std::fs::read_to_string(path) else { +fn worktree_config_enabled(path: &Path, info: &GitWorktreeInfo, reader: &mut ConfigReader) -> bool { + let Some(contents) = reader.read(path).1 else { return false; }; let mut section = ConfigSection::Other; @@ -124,13 +198,14 @@ fn collect_remote_urls( branch: &str, remote_urls: &mut Vec<(String, String)>, include_stack: &mut Vec<PathBuf>, + reader: &mut ConfigReader, ) { - let path = canonicalize_best_effort_path(path); + let (path, contents) = reader.read(path); if include_stack.contains(&path) { return; } include_stack.push(path.clone()); - let Ok(contents) = std::fs::read_to_string(&path) else { + let Some(contents) = contents else { include_stack.pop(); return; }; @@ -163,6 +238,7 @@ fn collect_remote_urls( branch, remote_urls, include_stack, + reader, ); } ConfigSection::IncludeIf(IncludeIfMode::Enabled) @@ -174,6 +250,7 @@ fn collect_remote_urls( branch, remote_urls, include_stack, + reader, ); } _ => {} @@ -189,13 +266,14 @@ fn merge_git_config( info: &GitWorktreeInfo, collect_hasconfig_urls: bool, include_stack: &mut Vec<PathBuf>, + reader: &mut ConfigReader, ) { - let path = canonicalize_best_effort_path(path); + let (path, contents) = reader.read(path); if include_stack.contains(&path) { return; } include_stack.push(path.clone()); - let Ok(contents) = std::fs::read_to_string(&path) else { + let Some(contents) = contents else { include_stack.pop(); return; }; @@ -232,6 +310,7 @@ fn merge_git_config( info, collect_hasconfig_urls, include_stack, + reader, ); } ConfigSection::IncludeIf(IncludeIfMode::Enabled) @@ -245,6 +324,7 @@ fn merge_git_config( info, collect_hasconfig_urls, include_stack, + reader, ); } ConfigSection::IncludeIf(IncludeIfMode::HasConfig) @@ -257,6 +337,7 @@ fn merge_git_config( info, config, include_stack, + reader, ) { merge_git_config( config, @@ -265,6 +346,7 @@ fn merge_git_config( info, collect_hasconfig_urls, include_stack, + reader, ); } } @@ -402,13 +484,14 @@ fn included_config_defines_remote_url( info: &GitWorktreeInfo, config: &BranchConfig, include_stack: &mut Vec<PathBuf>, + reader: &mut ConfigReader, ) -> bool { - let path = canonicalize_best_effort_path(path); + let (path, contents) = reader.read(path); if include_stack.contains(&path) { return false; } include_stack.push(path.clone()); - let Ok(contents) = std::fs::read_to_string(&path) else { + let Some(contents) = contents else { include_stack.pop(); return false; }; @@ -438,6 +521,7 @@ fn included_config_defines_remote_url( info, config, include_stack, + reader, ) { continue; } @@ -454,6 +538,7 @@ fn included_config_defines_remote_url( info, config, include_stack, + reader, ) { continue; } diff --git a/src/workspace/git/config_tests.rs b/src/workspace/git/config_tests.rs index e789e1db..d098f664 100644 --- a/src/workspace/git/config_tests.rs +++ b/src/workspace/git/config_tests.rs @@ -5,6 +5,41 @@ use crate::workspace::git::{ test_support::{temp_test_dir, write_fake_tracked_repo}, }; +#[cfg(unix)] +#[test] +fn config_symlink_retarget_invalidates_context() { + use std::os::unix::fs::symlink; + + let root = temp_test_dir("config-symlink-retarget"); + write_fake_tracked_repo(&root); + let alias = root.join("branch.cfg"); + let first = root.join("first.cfg"); + let second = root.join("second.cfg"); + std::fs::write(&first, "").unwrap(); + std::fs::write(&second, "").unwrap(); + symlink(&first, &alias).unwrap(); + let context = read_config_with_user_paths( + &git_worktree_info(&root).unwrap(), + "main", + vec![alias.clone()], + ); + std::fs::remove_file(&alias).unwrap(); + symlink(&second, &alias).unwrap(); + + assert!(!deps_current(&context.2)); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn config_read_error_retries_next_refresh() { + let root = temp_test_dir("config-read-error"); + write_fake_tracked_repo(&root); + std::fs::write(root.join(".git/config"), [0xff]).unwrap(); + let context = read_config(&git_worktree_info(&root).unwrap(), "main"); + assert!(!deps_current(&context.2)); + std::fs::remove_dir_all(root).unwrap(); +} + #[test] fn git_status_fingerprint_honors_remote_fetch_refspec() { let root = temp_test_dir("custom-fetch-refspec"); @@ -74,7 +109,9 @@ fn git_status_branch_config_reads_user_config_before_repo_config() { .unwrap(); let info = git_worktree_info(&root).unwrap(); - let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + let config = read_config_with_user_paths(&info, "main", vec![user_config]) + .1 + .unwrap(); assert_eq!(config.remote, "global"); assert_eq!( @@ -97,7 +134,9 @@ fn git_status_branch_config_repo_config_overrides_user_config() { .unwrap(); let info = git_worktree_info(&root).unwrap(); - let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + let config = read_config_with_user_paths(&info, "main", vec![user_config]) + .1 + .unwrap(); assert_eq!(config.remote, "origin"); assert_eq!( @@ -475,7 +514,9 @@ fn git_status_fingerprint_matches_user_hasconfig_against_repo_remote_url() { .unwrap(); let info = git_worktree_info(&root).unwrap(); - let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + let config = read_config_with_user_paths(&info, "main", vec![user_config]) + .1 + .unwrap(); assert_eq!(config.remote, "included"); assert_eq!(config.merge_ref, "refs/heads/main"); @@ -511,7 +552,9 @@ fn git_status_fingerprint_skips_hasconfig_include_that_defines_remote_url() { .unwrap(); let info = git_worktree_info(&root).unwrap(); - let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + let config = read_config_with_user_paths(&info, "main", vec![user_config]) + .1 + .unwrap(); assert_eq!(config.remote, "origin"); assert_eq!(config.merge_ref, "refs/heads/main"); @@ -552,7 +595,9 @@ fn git_status_fingerprint_skips_hasconfig_include_chain_that_defines_remote_url( .unwrap(); let info = git_worktree_info(&root).unwrap(); - let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + let config = read_config_with_user_paths(&info, "main", vec![user_config]) + .1 + .unwrap(); assert_eq!(config.remote, "origin"); assert_eq!(config.merge_ref, "refs/heads/main"); diff --git a/src/workspace/git/status.rs b/src/workspace/git/status.rs index dcfc1aff..4fb2b887 100644 --- a/src/workspace/git/status.rs +++ b/src/workspace/git/status.rs @@ -4,7 +4,7 @@ use std::time::{Duration, Instant}; use crate::workspace::{GitSpaceMetadata, WorkspaceGitStatusSnapshot}; use super::{ - config::{read_branch_config, upstream_full_ref}, + config::{deps_current, read_config, stamp, upstream_full_ref, ConfigCtx, FileDep}, discovery::{ automatic_workspace_label, canonicalize_best_effort_path, fallback_label_from_cwd, git_ref_storage_is_reftable, git_rev_parse_verify, git_space_metadata_from_info, @@ -39,10 +39,25 @@ pub struct GitStatusCacheEntry { #[derive(Debug, Clone, PartialEq, Eq)] pub struct GitStatusFingerprint { - pub git_dir: PathBuf, - pub git_common_dir: PathBuf, pub head: GitHeadIdentity, pub upstream: Option<GitUpstreamIdentity>, + repository_context: RepoContext, +} + +type RepoContext = (GitWorktreeInfo, bool, Vec<FileDep>, Option<ConfigCtx>); + +fn repo_context(cwd: &Path) -> Option<RepoContext> { + let info = git_worktree_info(cwd)?; + let reftable = git_ref_storage_is_reftable(&info.git_common_dir); + let mut paths = vec![info.repo_root.join(".git"), info.git_dir.join("commondir")]; + paths.push(info.git_dir.join("HEAD")); + paths.push(info.git_common_dir.join("config")); + paths.extend((info.git_dir != info.git_common_dir).then(|| info.git_dir.join("config"))); + let mut deps: Vec<_> = paths.into_iter().map(|path| stamp(path, None)).collect(); + deps[0].2 &= git_worktree_info(cwd).as_ref() == Some(&info) + && git_ref_storage_is_reftable(&info.git_common_dir) == reftable + && deps_current(&deps); + Some((info, reftable, deps, None)) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -95,7 +110,12 @@ pub fn git_status_snapshot_for_cwd_with_demand( return (cached.snapshot.clone(), Some(cached.clone())); } - let Some(info) = git_worktree_info(cwd) else { + let repository_context = cached + .and_then(|entry| entry.fingerprint.as_ref()) + .map(|fingerprint| fingerprint.repository_context.clone()) + .filter(|context| deps_current(&context.2)) + .or_else(|| repo_context(cwd)); + let Some(repository_context) = repository_context else { let snapshot = WorkspaceGitStatusSnapshot { auto_label: fallback_label_from_cwd(cwd), branch: None, @@ -111,31 +131,33 @@ pub fn git_status_snapshot_for_cwd_with_demand( }), ); }; - let auto_label = automatic_workspace_label(cwd, &info.repo_root); - let space = git_space_metadata_from_info(&info); + let auto_label = automatic_workspace_label(cwd, &repository_context.0.repo_root); + let space = git_space_metadata_from_info(&repository_context.0); if !demand.ahead_behind { + let fingerprint = fingerprint(repository_context, false); let branch = demand .branch - .then(|| { - read_head_identity(&info).and_then(|head| match head { - GitHeadIdentity::Branch { short_name, .. } => Some(short_name), - GitHeadIdentity::Detached { .. } => None, - }) - }) - .flatten(); + .then(|| fingerprint.as_ref()?.branch_name()) + .flatten() + .map(str::to_string); + let snapshot = WorkspaceGitStatusSnapshot { + auto_label, + branch, + ahead_behind: None, + space: Some(space), + }; return ( - WorkspaceGitStatusSnapshot { - auto_label, - branch, - ahead_behind: None, - space: Some(space), - }, - None, + snapshot.clone(), + fingerprint.map(|fingerprint| GitStatusCacheEntry { + fingerprint: Some(fingerprint), + retry_after: None, + snapshot, + }), ); } - let Some(fingerprint) = git_status_fingerprint_from_info(&info) else { + let Some(fingerprint) = fingerprint(repository_context, true) else { return ( WorkspaceGitStatusSnapshot { auto_label, @@ -187,22 +209,22 @@ pub fn git_status_snapshot_for_cwd_with_demand( #[cfg(test)] pub(super) fn git_status_fingerprint(cwd: &Path) -> Option<GitStatusFingerprint> { - let info = git_worktree_info(cwd)?; - git_status_fingerprint_from_info(&info) + fingerprint(repo_context(cwd)?, true) } -fn git_status_fingerprint_from_info(info: &GitWorktreeInfo) -> Option<GitStatusFingerprint> { - let head = read_head_identity(info)?; +fn fingerprint(mut repo: RepoContext, include_upstream: bool) -> Option<GitStatusFingerprint> { + let head = read_head_identity(&repo.0, repo.1)?; let upstream = match &head { - GitHeadIdentity::Branch { short_name, .. } => read_upstream_identity(info, short_name), - GitHeadIdentity::Detached { .. } => None, + GitHeadIdentity::Branch { short_name, .. } if include_upstream => { + read_upstream(&mut repo, short_name) + } + _ => None, }; Some(GitStatusFingerprint { - git_dir: canonicalize_best_effort_path(&info.git_dir), - git_common_dir: canonicalize_best_effort_path(&info.git_common_dir), head, upstream, + repository_context: repo, }) } @@ -228,8 +250,8 @@ impl GitStatusFingerprint { } } -fn read_head_identity(info: &GitWorktreeInfo) -> Option<GitHeadIdentity> { - if git_ref_storage_is_reftable(&info.git_common_dir) { +fn read_head_identity(info: &GitWorktreeInfo, reftable: bool) -> Option<GitHeadIdentity> { + if reftable { return read_head_identity_from_git(info); } @@ -268,13 +290,20 @@ fn read_head_identity_from_files(info: &GitWorktreeInfo) -> Option<GitHeadIdenti }) } -fn read_upstream_identity(info: &GitWorktreeInfo, branch: &str) -> Option<GitUpstreamIdentity> { - let config = read_branch_config(info, branch)?; +fn read_upstream(repo: &mut RepoContext, branch: &str) -> Option<GitUpstreamIdentity> { + if repo + .3 + .as_ref() + .is_none_or(|context| context.0 != branch || !deps_current(&context.2)) + { + repo.3 = Some(read_config(&repo.0, branch)); + } + let config = repo.3.as_ref()?.1.clone()?; let full_ref = upstream_full_ref(&config)?; - let oid = if git_ref_storage_is_reftable(&info.git_common_dir) { - git_rev_parse_verify(&info.repo_root, &full_ref) + let oid = if repo.1 { + git_rev_parse_verify(&repo.0.repo_root, &full_ref) } else { - read_ref_oid(&info.git_common_dir, &full_ref) + read_ref_oid(&repo.0.git_common_dir, &full_ref) }; Some(GitUpstreamIdentity { remote: config.remote, @@ -403,6 +432,19 @@ mod tests { } #[test] + fn cached_repo_identity_clears_when_head_disappears() { + let root = temp_test_dir("missing-head"); + write_fake_tracked_repo(&root); + let (_, cached) = git_status_snapshot_for_cwd(&root, None); + std::fs::remove_file(root.join(".git/HEAD")).unwrap(); + + let (snapshot, _) = git_status_snapshot_for_cwd(&root, cached.as_ref()); + + assert_eq!(snapshot.space, None); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] fn branch_only_refresh_skips_ahead_behind_cache_work() { let root = temp_test_dir("branch-only"); write_fake_tracked_repo(&root); @@ -418,7 +460,7 @@ mod tests { assert_eq!(snapshot.branch.as_deref(), Some("main")); assert_eq!(snapshot.ahead_behind, None); - assert_eq!(update, None); + assert!(update.is_some_and(|entry| entry.fingerprint.is_some())); std::fs::remove_dir_all(root).unwrap(); } @@ -509,6 +551,29 @@ mod tests { } #[test] + fn git_status_rebuilds_config_when_missing_include_appears() { + let root = temp_test_dir("include-appears"); + write_fake_tracked_repo(&root); + std::fs::write( + root.join(".git/config"), + "[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n[include]\n\tpath = branch.cfg\n", + ) + .unwrap(); + let (_, cached) = git_status_snapshot_for_cwd(&root, None); + std::fs::write( + root.join(".git/branch.cfg"), + "[branch \"main\"]\n\tremote = fork\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let (_, updated) = git_status_snapshot_for_cwd(&root, cached.as_ref()); + + let upstream = updated.unwrap().fingerprint.unwrap().upstream.unwrap(); + assert_eq!(upstream.remote, "fork"); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] fn git_status_fingerprint_reads_packed_refs() { let root = temp_test_dir("packed-refs"); write_fake_tracked_repo(&root); |
