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}; #[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)>, } type FileStamp = Option<(Option, u64)>; pub(super) type FileDep = (PathBuf, FileStamp, bool, Option); pub(super) fn stamp(path: PathBuf, target: Option) -> 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, Vec); #[derive(Default)] struct ConfigReader { files: HashMap>, deps: Vec, } impl ConfigReader { fn read(&mut self, path: &Path) -> (PathBuf, Option) { 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_config_with_user_paths( info: &GitWorktreeInfo, branch: &str, user_config_paths: Vec, ) -> ConfigCtx { let mut reader = ConfigReader::default(); let worktree_config_enabled = 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"))) .collect::>(); 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, &mut reader, ); } 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, &mut reader, ); } 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, &mut reader, ); } ( branch.to_string(), (!config.remote.is_empty() && !config.merge_ref.is_empty()).then_some(config), reader.deps, ) } fn git_user_config_paths() -> Vec { 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, reader: &mut ConfigReader) -> bool { let Some(contents) = reader.read(path).1 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, reader: &mut ConfigReader, ) { let (path, contents) = reader.read(path); if include_stack.contains(&path) { return; } include_stack.push(path.clone()); let Some(contents) = contents 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, reader, ); } 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, reader, ); } _ => {} } } include_stack.pop(); } fn merge_git_config( config: &mut BranchConfig, path: &Path, branch: &str, info: &GitWorktreeInfo, collect_hasconfig_urls: bool, include_stack: &mut Vec, reader: &mut ConfigReader, ) { let (path, contents) = reader.read(path); if include_stack.contains(&path) { return; } include_stack.push(path.clone()); let Some(contents) = contents 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, reader, ); } 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, reader, ); } 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, reader, ) { merge_git_config( config, &include_path, branch, info, collect_hasconfig_urls, include_stack, reader, ); } } _ => {} } } 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, reader: &mut ConfigReader, ) -> bool { let (path, contents) = reader.read(path); if include_stack.contains(&path) { return false; } include_stack.push(path.clone()); let Some(contents) = contents 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, reader, ) { 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, reader, ) { 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 { 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::>(); 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 { 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, } }