diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-30 23:58:15 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-30 23:58:34 +0300 |
| commit | fc4e1bbf6b0fa2ec37445a5b72356514c7c6be6a (patch) | |
| tree | 93af04af44a4dcc062a7886c51d70641c1903144 /src/workspace/git/config.rs | |
| parent | d477341e6af816ff4396042b084a0378ef437b4a (diff) | |
perf: reduce sidebar git polling overhead
refs #353
Diffstat (limited to 'src/workspace/git/config.rs')
| -rw-r--r-- | src/workspace/git/config.rs | 659 |
1 files changed, 659 insertions, 0 deletions
diff --git a/src/workspace/git/config.rs b/src/workspace/git/config.rs new file mode 100644 index 00000000..9783387c --- /dev/null +++ b/src/workspace/git/config.rs @@ -0,0 +1,659 @@ +use std::path::{Path, PathBuf}; + +use super::discovery::{canonicalize_best_effort_path, GitWorktreeInfo}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct BranchConfig { + pub(super) remote: String, + pub(super) merge_ref: String, + fetch_refspecs: Vec<(String, String)>, + 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()) +} + +pub(super) fn read_branch_config_with_user_paths( + info: &GitWorktreeInfo, + branch: &str, + user_config_paths: Vec<PathBuf>, +) -> Option<BranchConfig> { + let worktree_config_enabled = + worktree_config_enabled(&info.git_common_dir.join("config"), info); + let config_paths = user_config_paths + .into_iter() + .chain(std::iter::once(info.git_common_dir.join("config"))) + .collect::<Vec<_>>(); + 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); + } + let mut config = BranchConfig { + remote: String::new(), + merge_ref: String::new(), + fetch_refspecs: Vec::new(), + remote_urls, + }; + for path in config_paths { + let mut include_stack = Vec::new(); + merge_git_config(&mut config, &path, branch, info, true, &mut include_stack); + } + if worktree_config_enabled { + let mut include_stack = Vec::new(); + merge_git_config( + &mut config, + &info.git_dir.join("config.worktree"), + branch, + info, + false, + &mut include_stack, + ); + } + (!config.remote.is_empty() && !config.merge_ref.is_empty()).then_some(config) +} + +fn git_user_config_paths() -> Vec<PathBuf> { + let mut paths = Vec::new(); + if let Some(xdg_config_home) = std::env::var_os("XDG_CONFIG_HOME") { + paths.push(PathBuf::from(xdg_config_home).join("git/config")); + } else if let Some(home) = std::env::var_os("HOME") { + paths.push(PathBuf::from(home).join(".config/git/config")); + } + if let Some(home) = std::env::var_os("HOME") { + paths.push(PathBuf::from(home).join(".gitconfig")); + } + paths +} + +fn worktree_config_enabled(path: &Path, info: &GitWorktreeInfo) -> bool { + let Ok(contents) = std::fs::read_to_string(path) else { + return false; + }; + let mut section = ConfigSection::Other; + let mut enabled = false; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if let Some(section_name) = extract_config_section(line) { + let is_extensions = section_name.eq_ignore_ascii_case("extensions"); + section = if is_extensions { + ConfigSection::Extensions + } else { + parse_config_section( + section_name, + "", + info, + path, + &BranchConfig { + remote: String::new(), + merge_ref: String::new(), + fetch_refspecs: Vec::new(), + remote_urls: Vec::new(), + }, + ) + }; + continue; + } + if let Some((key, value)) = line.split_once('=') { + let key = key.trim(); + let value = normalize_config_value(value); + match §ion { + ConfigSection::Extensions if key.eq_ignore_ascii_case("worktreeConfig") => { + enabled = matches!( + value.to_ascii_lowercase().as_str(), + "true" | "1" | "yes" | "on" + ); + } + _ => {} + } + continue; + } + if matches!(section, ConfigSection::Extensions) + && line.eq_ignore_ascii_case("worktreeConfig") + { + enabled = true; + } + } + enabled +} + +fn collect_remote_urls( + path: &Path, + info: &GitWorktreeInfo, + branch: &str, + remote_urls: &mut Vec<(String, String)>, + include_stack: &mut Vec<PathBuf>, +) { + let path = canonicalize_best_effort_path(path); + if include_stack.contains(&path) { + return; + } + include_stack.push(path.clone()); + let Ok(contents) = std::fs::read_to_string(&path) else { + include_stack.pop(); + return; + }; + let mut section = ConfigSection::Other; + let dummy_config = BranchConfig { + remote: String::new(), + merge_ref: String::new(), + fetch_refspecs: Vec::new(), + remote_urls: remote_urls.clone(), + }; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if let Some(section_name) = extract_config_section(line) { + section = parse_config_section(section_name, branch, info, &path, &dummy_config); + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = normalize_config_value(value); + match §ion { + ConfigSection::Remote(remote) if key.eq_ignore_ascii_case("url") => { + remote_urls.push((remote.clone(), value)); + } + ConfigSection::Include if key.eq_ignore_ascii_case("path") => { + collect_remote_urls( + &resolve_include_path(&path, &value), + info, + branch, + remote_urls, + include_stack, + ); + } + ConfigSection::IncludeIf(IncludeIfMode::Enabled) + if key.eq_ignore_ascii_case("path") => + { + collect_remote_urls( + &resolve_include_path(&path, &value), + info, + branch, + remote_urls, + include_stack, + ); + } + _ => {} + } + } + include_stack.pop(); +} + +fn merge_git_config( + config: &mut BranchConfig, + path: &Path, + branch: &str, + info: &GitWorktreeInfo, + collect_hasconfig_urls: bool, + include_stack: &mut Vec<PathBuf>, +) { + let path = canonicalize_best_effort_path(path); + if include_stack.contains(&path) { + return; + } + include_stack.push(path.clone()); + let Ok(contents) = std::fs::read_to_string(&path) else { + include_stack.pop(); + return; + }; + let mut section = ConfigSection::Other; + + for raw_line in contents.lines() { + let line = raw_line.trim(); + if let Some(section_name) = extract_config_section(line) { + section = parse_config_section(section_name, branch, info, &path, config); + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = normalize_config_value(value); + match §ion { + ConfigSection::Branch if key.eq_ignore_ascii_case("remote") => config.remote = value, + ConfigSection::Branch if key.eq_ignore_ascii_case("merge") => config.merge_ref = value, + ConfigSection::Remote(remote) if key.eq_ignore_ascii_case("fetch") => { + config.fetch_refspecs.push((remote.clone(), value)); + } + ConfigSection::Remote(remote) + if collect_hasconfig_urls && key.eq_ignore_ascii_case("url") => + { + config.remote_urls.push((remote.clone(), value)); + } + ConfigSection::Include if key.eq_ignore_ascii_case("path") => { + let include_path = resolve_include_path(&path, &value); + merge_git_config( + config, + &include_path, + branch, + info, + collect_hasconfig_urls, + include_stack, + ); + } + ConfigSection::IncludeIf(IncludeIfMode::Enabled) + if key.eq_ignore_ascii_case("path") => + { + let include_path = resolve_include_path(&path, &value); + merge_git_config( + config, + &include_path, + branch, + info, + collect_hasconfig_urls, + include_stack, + ); + } + ConfigSection::IncludeIf(IncludeIfMode::HasConfig) + if key.eq_ignore_ascii_case("path") => + { + let include_path = resolve_include_path(&path, &value); + if !included_config_defines_remote_url( + &include_path, + branch, + info, + config, + include_stack, + ) { + merge_git_config( + config, + &include_path, + branch, + info, + collect_hasconfig_urls, + include_stack, + ); + } + } + _ => {} + } + } + include_stack.pop(); +} + +enum ConfigSection { + Branch, + Extensions, + Include, + IncludeIf(IncludeIfMode), + Remote(String), + Other, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum IncludeIfMode { + Disabled, + Enabled, + HasConfig, +} + +fn extract_config_section(line: &str) -> Option<&str> { + if !line.starts_with('[') { + return None; + } + let mut in_quotes = false; + let mut escaped = false; + for (index, ch) in line.char_indices().skip(1) { + if escaped { + escaped = false; + continue; + } + match ch { + '\\' if in_quotes => escaped = true, + '"' => in_quotes = !in_quotes, + ']' if !in_quotes => { + let rest = line[index + 1..].trim(); + if rest.is_empty() || rest.starts_with('#') || rest.starts_with(';') { + return Some(&line[1..index]); + } + return None; + } + _ => {} + } + } + None +} + +fn parse_config_section( + section: &str, + branch: &str, + info: &GitWorktreeInfo, + config_path: &Path, + config: &BranchConfig, +) -> ConfigSection { + if let Some(name) = quoted_config_subsection(section, "branch") { + return if name == branch { + ConfigSection::Branch + } else { + ConfigSection::Other + }; + } + if let Some(name) = quoted_config_subsection(section, "remote") { + return ConfigSection::Remote(name.to_string()); + } + if section.eq_ignore_ascii_case("include") { + return ConfigSection::Include; + } + if let Some(condition) = quoted_config_subsection(section, "includeIf") { + return ConfigSection::IncludeIf(include_if_mode( + condition, + info, + config_path, + branch, + config, + )); + } + ConfigSection::Other +} + +fn include_if_mode( + condition: &str, + info: &GitWorktreeInfo, + config_path: &Path, + branch: &str, + config: &BranchConfig, +) -> IncludeIfMode { + let (case_insensitive, pattern) = if let Some(pattern) = condition.strip_prefix("gitdir/i:") { + (true, pattern) + } else if let Some(pattern) = condition.strip_prefix("gitdir:") { + (false, pattern) + } else if let Some(pattern) = condition.strip_prefix("onbranch:") { + let pattern = normalize_branch_include_pattern(pattern); + return if wildcard_match(&pattern, branch, false) { + IncludeIfMode::Enabled + } else { + IncludeIfMode::Disabled + }; + } else if let Some(pattern) = condition.strip_prefix("hasconfig:remote.*.url:") { + return if config + .remote_urls + .iter() + .any(|(_, url)| wildcard_match(pattern, url, false)) + { + IncludeIfMode::HasConfig + } else { + IncludeIfMode::Disabled + }; + } else { + return IncludeIfMode::Disabled; + }; + let pattern = normalize_gitdir_include_pattern(pattern, config_path); + let candidates = [ + info.git_dir.display().to_string(), + info.git_common_dir.display().to_string(), + info.repo_root.join(".git").display().to_string(), + ]; + if candidates + .iter() + .any(|candidate| wildcard_match(&pattern, candidate, case_insensitive)) + { + IncludeIfMode::Enabled + } else { + IncludeIfMode::Disabled + } +} + +fn included_config_defines_remote_url( + path: &Path, + branch: &str, + info: &GitWorktreeInfo, + config: &BranchConfig, + include_stack: &mut Vec<PathBuf>, +) -> bool { + let path = canonicalize_best_effort_path(path); + if include_stack.contains(&path) { + return false; + } + include_stack.push(path.clone()); + let Ok(contents) = std::fs::read_to_string(&path) else { + include_stack.pop(); + return false; + }; + let mut section = ConfigSection::Other; + let mut defines_remote_url = false; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if let Some(section_name) = extract_config_section(line) { + section = parse_config_section(section_name, branch, info, &path, config); + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + if matches!(section, ConfigSection::Remote(_)) && key.eq_ignore_ascii_case("url") { + defines_remote_url = true; + break; + } + let value = normalize_config_value(value); + match §ion { + ConfigSection::Include if key.eq_ignore_ascii_case("path") => { + let include_path = resolve_include_path(&path, &value); + if !included_config_defines_remote_url( + &include_path, + branch, + info, + config, + include_stack, + ) { + continue; + } + defines_remote_url = true; + break; + } + ConfigSection::IncludeIf(IncludeIfMode::Enabled | IncludeIfMode::HasConfig) + if key.eq_ignore_ascii_case("path") => + { + let include_path = resolve_include_path(&path, &value); + if !included_config_defines_remote_url( + &include_path, + branch, + info, + config, + include_stack, + ) { + continue; + } + defines_remote_url = true; + break; + } + _ => {} + } + } + include_stack.pop(); + defines_remote_url +} + +fn normalize_branch_include_pattern(pattern: &str) -> String { + if pattern.ends_with('/') { + format!("{pattern}**") + } else { + pattern.to_string() + } +} + +fn normalize_gitdir_include_pattern(pattern: &str, config_path: &Path) -> String { + let mut pattern = if let Some(rest) = pattern.strip_prefix("~/") { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_default() + .join(rest) + .display() + .to_string() + } else if let Some(rest) = pattern.strip_prefix("./") { + config_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(rest) + .display() + .to_string() + } else if Path::new(pattern).is_absolute() { + pattern.to_string() + } else { + format!("**/{pattern}") + }; + if pattern.ends_with('/') { + pattern.push_str("**"); + } + pattern +} + +fn wildcard_match(pattern: &str, value: &str, case_insensitive: bool) -> bool { + let pattern = if case_insensitive { + pattern.to_ascii_lowercase() + } else { + pattern.to_string() + }; + let value = if case_insensitive { + value.to_ascii_lowercase() + } else { + value.to_string() + }; + wildcard_match_bytes(pattern.as_bytes(), value.as_bytes()) +} + +fn wildcard_match_bytes(pattern: &[u8], value: &[u8]) -> bool { + match pattern.split_first() { + None => value.is_empty(), + Some((&b'*', rest)) => { + wildcard_match_bytes(rest, value) + || (!value.is_empty() && wildcard_match_bytes(pattern, &value[1..])) + } + Some((&expected, rest)) => value.split_first().is_some_and(|(&actual, value_rest)| { + actual == expected && wildcard_match_bytes(rest, value_rest) + }), + } +} + +fn quoted_config_subsection<'a>(section: &'a str, name: &str) -> Option<&'a str> { + let prefix_len = name.len() + 2; + if section.len() <= prefix_len { + return None; + } + let prefix = §ion[..prefix_len]; + if !prefix.eq_ignore_ascii_case(&format!("{name} \"")) { + return None; + } + section[prefix_len..].strip_suffix('"') +} + +fn resolve_include_path(config_path: &Path, include_path: &str) -> PathBuf { + let include_path = include_path.strip_prefix("~/").map_or_else( + || PathBuf::from(include_path), + |rest| { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_default() + .join(rest) + }, + ); + if include_path.is_absolute() { + include_path + } else { + config_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(include_path) + } +} + +fn normalize_config_value(value: &str) -> String { + let value = value.trim(); + let mut in_quotes = false; + let mut escaped = false; + for (index, ch) in value.char_indices() { + if escaped { + escaped = false; + continue; + } + match ch { + '\\' if in_quotes => escaped = true, + '"' => in_quotes = !in_quotes, + '#' | ';' + if !in_quotes + && value[..index] + .chars() + .next_back() + .is_some_and(char::is_whitespace) => + { + return unquote_config_value(value[..index].trim()); + } + _ => {} + } + } + unquote_config_value(value) +} + +fn unquote_config_value(value: &str) -> String { + value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .unwrap_or(value) + .to_string() +} + +pub(super) fn upstream_full_ref(config: &BranchConfig) -> Option<String> { + if config.remote == "." { + return Some(config.merge_ref.clone()); + } + let default_refspec = format!("+refs/heads/*:refs/remotes/{}/*", config.remote); + let remote_refspecs = config + .fetch_refspecs + .iter() + .filter(|(remote, _)| remote == &config.remote) + .map(|(_, refspec)| refspec); + let refspecs = remote_refspecs.collect::<Vec<_>>(); + if refspecs.is_empty() { + return map_fetch_refspec(&default_refspec, &config.merge_ref).into_ref(); + } + refspecs + .into_iter() + .find_map(|refspec| map_fetch_refspec(refspec, &config.merge_ref).into_ref()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum FetchRefspecMatch { + Ref(String), + NoMatch, +} + +impl FetchRefspecMatch { + fn into_ref(self) -> Option<String> { + match self { + FetchRefspecMatch::Ref(value) => Some(value), + FetchRefspecMatch::NoMatch => None, + } + } +} + +fn map_fetch_refspec(refspec: &str, merge_ref: &str) -> FetchRefspecMatch { + let refspec = refspec.strip_prefix('+').unwrap_or(refspec); + if refspec.starts_with('^') { + return FetchRefspecMatch::NoMatch; + } + let Some((source, destination)) = refspec.split_once(':') else { + return FetchRefspecMatch::NoMatch; + }; + match (source.split_once('*'), destination.split_once('*')) { + (None, None) => { + if source == merge_ref { + FetchRefspecMatch::Ref(destination.to_string()) + } else { + FetchRefspecMatch::NoMatch + } + } + (Some((source_prefix, source_suffix)), Some((destination_prefix, destination_suffix))) => { + let Some(matched) = merge_ref + .strip_prefix(source_prefix) + .and_then(|matched| matched.strip_suffix(source_suffix)) + else { + return FetchRefspecMatch::NoMatch; + }; + FetchRefspecMatch::Ref(format!("{destination_prefix}{matched}{destination_suffix}")) + } + _ => FetchRefspecMatch::NoMatch, + } +} |
