diff options
Diffstat (limited to 'src/workspace')
| -rw-r--r-- | src/workspace/git/config.rs | 659 | ||||
| -rw-r--r-- | src/workspace/git/config_tests.rs | 733 | ||||
| -rw-r--r-- | src/workspace/git/discovery.rs (renamed from src/workspace/git.rs) | 146 | ||||
| -rw-r--r-- | src/workspace/git/mod.rs | 15 | ||||
| -rw-r--r-- | src/workspace/git/status.rs | 386 | ||||
| -rw-r--r-- | src/workspace/git/test_support.rs | 51 |
6 files changed, 1934 insertions, 56 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, + } +} diff --git a/src/workspace/git/config_tests.rs b/src/workspace/git/config_tests.rs new file mode 100644 index 00000000..e789e1db --- /dev/null +++ b/src/workspace/git/config_tests.rs @@ -0,0 +1,733 @@ +use super::config::*; +use crate::workspace::git::{ + discovery::git_worktree_info, + status::git_status_fingerprint, + test_support::{temp_test_dir, write_fake_tracked_repo}, +}; + +#[test] +fn git_status_fingerprint_honors_remote_fetch_refspec() { + let root = temp_test_dir("custom-fetch-refspec"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/upstream")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/upstream/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"origin\"]\n\tfetch = +refs/heads/*:refs/remotes/upstream/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.full_ref, "refs/remotes/upstream/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_reads_included_config() { + let root = temp_test_dir("included-config"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[include]\n\tpath = included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_branch_config_reads_user_config_before_repo_config() { + let root = temp_test_dir("user-config"); + write_fake_tracked_repo(&root); + let user_config = root.join("user.gitconfig"); + std::fs::write(root.join(".git/config"), "").unwrap(); + std::fs::write( + &user_config, + "[remote \"global\"]\n\tfetch = +refs/heads/*:refs/remotes/global/*\n[branch \"main\"]\n\tremote = global\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let info = git_worktree_info(&root).unwrap(); + let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + + assert_eq!(config.remote, "global"); + assert_eq!( + upstream_full_ref(&config).as_deref(), + Some("refs/remotes/global/main") + ); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_branch_config_repo_config_overrides_user_config() { + let root = temp_test_dir("repo-overrides-user-config"); + write_fake_tracked_repo(&root); + let user_config = root.join("user.gitconfig"); + std::fs::write( + &user_config, + "[remote \"global\"]\n\tfetch = +refs/heads/*:refs/remotes/global/*\n[branch \"main\"]\n\tremote = global\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let info = git_worktree_info(&root).unwrap(); + let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + + assert_eq!(config.remote, "origin"); + assert_eq!( + upstream_full_ref(&config).as_deref(), + Some("refs/remotes/origin/main") + ); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_applies_repeated_includes_in_order() { + let root = temp_test_dir("repeated-include"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[include]\n\tpath = included.cfg\n[branch \"main\"]\n\tremote = middle\n[include]\n\tpath = included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_reads_matching_include_if_config() { + let root = temp_test_dir("include-if-config"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + format!( + "[includeIf \"gitdir:{}\"]\n\tpath = included.cfg\n", + root.join(".git").display() + ), + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_matches_gitdir_include_if_directory_pattern() { + let base = temp_test_dir("include-if-dir"); + let root = base.join("work/repo"); + std::fs::create_dir_all(&root).unwrap(); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + format!( + "[includeIf \"gitdir:{}/\"]\n\tpath = included.cfg\n", + base.join("work").display() + ), + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(base).unwrap(); +} + +#[test] +fn git_status_fingerprint_reads_case_insensitive_config_keys() { + let root = temp_test_dir("case-insensitive-config"); + write_fake_tracked_repo(&root); + std::fs::write( + root.join(".git/config"), + "[Remote \"origin\"] # remote section\n\tFetch = +refs/heads/*:refs/remotes/origin/*\n[Branch \"main\"] ; branch section\n\tRemote = origin\n\tMerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "origin"); + assert_eq!(upstream.full_ref, "refs/remotes/origin/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_keeps_refspecs_for_later_remote_override() { + let root = temp_test_dir("worktree-remote-override"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/fork/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[extensions]\n\tworktreeConfig = true\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config.worktree"), + "[branch \"main\"]\n\tremote = fork\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "fork"); + assert_eq!(upstream.full_ref, "refs/remotes/fork/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_ignores_worktree_config_when_extension_disabled() { + let root = temp_test_dir("worktree-config-disabled"); + write_fake_tracked_repo(&root); + std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/fork/main"), + "3333333333333333333333333333333333333333\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config.worktree"), + "[branch \"main\"]\n\tremote = fork\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "origin"); + assert_eq!(upstream.full_ref, "refs/remotes/origin/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_accepts_git_boolean_worktree_config() { + let root = temp_test_dir("worktree-config-boolean"); + write_fake_tracked_repo(&root); + std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/fork/main"), + "3333333333333333333333333333333333333333\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[extensions]\n\tworktreeConfig\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config.worktree"), + "[branch \"main\"]\n\tremote = fork\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "fork"); + assert_eq!(upstream.full_ref, "refs/remotes/fork/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_uses_last_worktree_config_boolean() { + let root = temp_test_dir("worktree-config-duplicate-boolean"); + write_fake_tracked_repo(&root); + std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/fork/main"), + "3333333333333333333333333333333333333333\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[extensions]\n\tworktreeConfig = false\n\tworktreeConfig = true\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config.worktree"), + "[branch \"main\"]\n\tremote = fork\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "fork"); + assert_eq!(upstream.full_ref, "refs/remotes/fork/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_ignores_included_worktree_config_extension() { + let root = temp_test_dir("worktree-config-included-extension"); + write_fake_tracked_repo(&root); + std::fs::create_dir_all(root.join(".git/refs/remotes/fork")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/fork/main"), + "3333333333333333333333333333333333333333\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[include]\n\tpath = extension.cfg\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/extension.cfg"), + "[extensions]\n\tworktreeConfig = true\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config.worktree"), + "[branch \"main\"]\n\tremote = fork\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "origin"); + assert_eq!(upstream.full_ref, "refs/remotes/origin/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_reads_onbranch_include_if_config() { + let root = temp_test_dir("include-if-onbranch"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[includeIf \"onbranch:main\"]\n\tpath = included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_reads_hasconfig_include_if_config() { + let root = temp_test_dir("include-if-hasconfig"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"fork\"]\n\turl = https://example.test/fork.git\n[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_matches_user_hasconfig_against_repo_remote_url() { + let root = temp_test_dir("include-if-hasconfig-user-repo-url"); + let user_config = root.join("user.gitconfig"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"fork\"]\n\turl = https://example.test/fork.git\n", + ) + .unwrap(); + std::fs::write( + &user_config, + "[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = user-included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join("user-included.cfg"), + "[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let info = git_worktree_info(&root).unwrap(); + let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + + assert_eq!(config.remote, "included"); + assert_eq!(config.merge_ref, "refs/heads/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_skips_hasconfig_include_that_defines_remote_url() { + let root = temp_test_dir("include-if-hasconfig-rejects-remote-url"); + let user_config = root.join("user.gitconfig"); + write_fake_tracked_repo(&root); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "3333333333333333333333333333333333333333\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"fork\"]\n\turl = https://example.test/fork.git\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + &user_config, + "[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = user-included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join("user-included.cfg"), + "[remote \"included\"]\n\turl = https://example.test/included.git\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let info = git_worktree_info(&root).unwrap(); + let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + + assert_eq!(config.remote, "origin"); + assert_eq!(config.merge_ref, "refs/heads/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_skips_hasconfig_include_chain_that_defines_remote_url() { + let root = temp_test_dir("include-if-hasconfig-rejects-nested-remote-url"); + let user_config = root.join("user.gitconfig"); + write_fake_tracked_repo(&root); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "3333333333333333333333333333333333333333\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"fork\"]\n\turl = https://example.test/fork.git\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + &user_config, + "[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = user-included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join("user-included.cfg"), + "[include]\n\tpath = nested-remote.cfg\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join("nested-remote.cfg"), + "[remote \"included\"]\n\turl = https://example.test/included.git\n\tfetch = +refs/heads/*:refs/remotes/included/*\n", + ) + .unwrap(); + + let info = git_worktree_info(&root).unwrap(); + let config = read_branch_config_with_user_paths(&info, "main", vec![user_config]).unwrap(); + + assert_eq!(config.remote, "origin"); + assert_eq!(config.merge_ref, "refs/heads/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_ignores_worktree_urls_for_hasconfig() { + let root = temp_test_dir("include-if-hasconfig-worktree-url"); + write_fake_tracked_repo(&root); + std::fs::write( + root.join(".git/config"), + "[extensions]\n\tworktreeConfig = true\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config.worktree"), + "[remote \"fork\"]\n\turl = https://example.test/fork.git\n[includeIf \"hasconfig:remote.*.url:*fork.git\"]\n\tpath = included.cfg\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/included.cfg"), + "[branch \"main\"]\n\tremote = included\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "origin"); + assert_eq!(upstream.full_ref, "refs/remotes/origin/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_stops_recursive_include_cycles() { + let root = temp_test_dir("include-cycle"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/included")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/included/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write(root.join(".git/config"), "[include]\n\tpath = a.cfg\n").unwrap(); + std::fs::write(root.join(".git/a.cfg"), "[include]\n\tpath = b.cfg\n").unwrap(); + std::fs::write( + root.join(".git/b.cfg"), + "[include]\n\tpath = a.cfg\n[remote \"included\"]\n\tfetch = +refs/heads/*:refs/remotes/included/*\n[branch \"main\"]\n\tremote = included\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "included"); + assert_eq!(upstream.full_ref, "refs/remotes/included/main"); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_reads_linked_worktree_config() { + let base = temp_test_dir("linked-worktree-config"); + let common_dir = base.join("repo/.git"); + let worktree = base.join("linked"); + let git_dir = common_dir.join("worktrees/linked"); + std::fs::create_dir_all(common_dir.join("refs/heads")).unwrap(); + std::fs::create_dir_all(common_dir.join("refs/remotes/fork")).unwrap(); + std::fs::create_dir_all(&git_dir).unwrap(); + std::fs::create_dir_all(&worktree).unwrap(); + std::fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", git_dir.display()), + ) + .unwrap(); + std::fs::write(git_dir.join("commondir"), "../..\n").unwrap(); + std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + std::fs::write( + common_dir.join("refs/heads/main"), + "1111111111111111111111111111111111111111\n", + ) + .unwrap(); + std::fs::write( + common_dir.join("refs/remotes/fork/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + common_dir.join("config"), + "[extensions]\n\tworktreeConfig = TRUE\n[remote \"fork\"]\n\tfetch = +refs/heads/*:refs/remotes/fork/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + git_dir.join("config.worktree"), + "[branch \"main\"]\n\tremote = fork\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&worktree).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.remote, "fork"); + assert_eq!(upstream.full_ref, "refs/remotes/fork/main"); + + std::fs::remove_dir_all(base).unwrap(); +} + +#[test] +fn git_status_fingerprint_ignores_inline_fetch_refspec_comment() { + let root = temp_test_dir("commented-fetch-refspec"); + write_fake_tracked_repo(&root); + std::fs::remove_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/upstream")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/upstream/main"), + "2222222222222222222222222222222222222222\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[remote \"origin\"]\n\tfetch = +refs/heads/*:refs/remotes/upstream/* # custom map\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.full_ref, "refs/remotes/upstream/main"); + assert_eq!( + upstream.oid.as_deref(), + Some("2222222222222222222222222222222222222222") + ); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_clears_upstream_for_unmapped_refspec() { + let root = temp_test_dir("unmapped-fetch-refspec"); + write_fake_tracked_repo(&root); + std::fs::write( + root.join(".git/config"), + "[remote \"origin\"]\n\tfetch = +refs/pull/*:refs/remotes/origin/pr/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + assert_eq!(fingerprint.upstream, None); + + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn git_status_fingerprint_honors_negative_fetch_refspec() { + let root = temp_test_dir("negative-fetch-refspec"); + write_fake_tracked_repo(&root); + std::fs::write( + root.join(".git/config"), + "[remote \"origin\"]\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n\tfetch = ^refs/heads/main\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + let upstream = fingerprint.upstream.unwrap(); + assert_eq!(upstream.full_ref, "refs/remotes/origin/main"); + assert_eq!( + upstream.oid.as_deref(), + Some("2222222222222222222222222222222222222222") + ); + + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/src/workspace/git.rs b/src/workspace/git/discovery.rs index d06c8538..de4b090c 100644 --- a/src/workspace/git.rs +++ b/src/workspace/git/discovery.rs @@ -40,44 +40,23 @@ pub fn derive_label_from_cwd(cwd: &Path) -> String { } pub fn git_worktree_info(cwd: &Path) -> Option<GitWorktreeInfo> { - let repo_root = git_rev_parse(cwd, &["--show-toplevel"])?; - let git_dir = git_rev_parse(cwd, &["--path-format=absolute", "--git-dir"])?; - let git_common_dir = git_rev_parse(cwd, &["--path-format=absolute", "--git-common-dir"])?; - let is_bare = git_rev_parse(cwd, &["--is-bare-repository"])? == "true"; - let is_linked_worktree = - canonicalize_best_effort(&git_dir) != canonicalize_best_effort(&git_common_dir); + let repo_root = git_repo_root(cwd)?; + let git_dir = canonicalize_best_effort_path(&git_dir_for_repo_root(&repo_root)?); + let git_common_dir = canonicalize_best_effort_path(&git_common_dir_for_git_dir(&git_dir)); + let is_linked_worktree = git_dir != git_common_dir; Some(GitWorktreeInfo { - repo_root: PathBuf::from(repo_root), - git_dir: PathBuf::from(git_dir), - git_common_dir: PathBuf::from(git_common_dir), - is_bare, + repo_root, + git_dir, + git_common_dir, + is_bare: false, is_linked_worktree, }) } -fn git_rev_parse(cwd: &Path, args: &[&str]) -> Option<String> { - let output = std::process::Command::new("git") - .arg("-C") - .arg(cwd) - .arg("rev-parse") - .args(args) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8(output.stdout).ok()?; - Some(stdout.trim().to_string()) -} - -fn canonicalize_best_effort(path: &str) -> PathBuf { - std::fs::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path)) -} - pub fn git_space_metadata(cwd: &Path) -> Option<GitSpaceMetadata> { + git_repo_root(cwd)?; + let info = git_worktree_info(cwd)?; if info.is_bare { return None; @@ -112,10 +91,23 @@ pub fn git_space_metadata(cwd: &Path) -> Option<GitSpaceMetadata> { }) } -fn canonicalize_best_effort_path(path: &Path) -> PathBuf { +pub(super) fn canonicalize_best_effort_path(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } +fn git_common_dir_for_git_dir(git_dir: &Path) -> PathBuf { + let commondir = git_dir.join("commondir"); + let Ok(contents) = std::fs::read_to_string(commondir) else { + return git_dir.to_path_buf(); + }; + let path = Path::new(contents.trim()); + if path.is_absolute() { + path.to_path_buf() + } else { + git_dir.join(path) + } +} + pub fn git_branch(cwd: &Path) -> Option<String> { let repo_root = git_repo_root(cwd)?; let git_dir = git_dir_for_repo_root(&repo_root)?; @@ -123,7 +115,7 @@ pub fn git_branch(cwd: &Path) -> Option<String> { parse_git_head_branch(&head) } -fn git_dir_for_repo_root(repo_root: &Path) -> Option<PathBuf> { +pub(super) fn git_dir_for_repo_root(repo_root: &Path) -> Option<PathBuf> { let git_path = repo_root.join(".git"); if git_path.is_dir() { return Some(git_path); @@ -144,7 +136,7 @@ fn parse_git_head_branch(head: &str) -> Option<String> { (!branch.is_empty()).then(|| branch.to_string()) } -fn git_repo_root(start: &Path) -> Option<PathBuf> { +pub(super) fn git_repo_root(start: &Path) -> Option<PathBuf> { let mut current = if start.is_dir() { start.to_path_buf() } else { @@ -152,7 +144,10 @@ fn git_repo_root(start: &Path) -> Option<PathBuf> { }; loop { - if current.join(".git").exists() { + if git_dir_for_repo_root(¤t) + .map(|git_dir| git_dir.join("HEAD").is_file()) + .unwrap_or(false) + { return Some(current); } if !current.pop() { @@ -161,33 +156,34 @@ fn git_repo_root(start: &Path) -> Option<PathBuf> { } } -pub(super) fn git_ahead_behind(cwd: &Path) -> Option<(usize, usize)> { - git_repo_root(cwd)?; - - let output = std::process::Command::new("git") - .arg("-C") - .arg(cwd) - .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]) - .output() - .ok()?; - - if !output.status.success() { - return None; +pub(super) fn read_ref_oid(common_dir: &Path, full_ref: &str) -> Option<String> { + let loose_ref = common_dir.join(full_ref); + if let Ok(contents) = std::fs::read_to_string(loose_ref) { + let oid = contents.trim(); + if !oid.is_empty() { + return Some(oid.to_string()); + } } - let stdout = String::from_utf8(output.stdout).ok()?; - parse_git_ahead_behind_output(&stdout) -} - -fn parse_git_ahead_behind_output(stdout: &str) -> Option<(usize, usize)> { - let mut parts = stdout.split_whitespace(); - let ahead = parts.next()?.parse().ok()?; - let behind = parts.next()?.parse().ok()?; - Some((ahead, behind)) + let packed_refs = std::fs::read_to_string(common_dir.join("packed-refs")).ok()?; + for line in packed_refs.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with('^') { + continue; + } + let mut parts = line.split_whitespace(); + let oid = parts.next()?; + let name = parts.next()?; + if name == full_ref { + return Some(oid.to_string()); + } + } + None } #[cfg(test)] mod tests { + use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use super::*; @@ -241,4 +237,42 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + + #[test] + fn git_repo_root_ignores_invalid_git_marker() { + let base = temp_test_dir("invalid-git-root"); + let cwd = base.join("workspace"); + std::fs::create_dir_all(base.join(".git")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + assert_eq!(git_repo_root(&cwd), None); + + std::fs::remove_dir_all(base).unwrap(); + } + + #[test] + fn derive_label_prefers_repo_root_name() { + let root = temp_test_dir("label-repo"); + let nested = root.join("nested"); + std::fs::create_dir_all(root.join(".git")).unwrap(); + std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + std::fs::create_dir_all(&nested).unwrap(); + + assert_eq!( + derive_label_from_cwd(&nested), + root.file_name().and_then(|name| name.to_str()).unwrap() + ); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn derive_label_uses_path_name_outside_git() { + let root = temp_test_dir("label-plain"); + let label = root.file_name().and_then(|name| name.to_str()).unwrap(); + + assert_eq!(derive_label_from_cwd(Path::new(&root)), label); + + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/src/workspace/git/mod.rs b/src/workspace/git/mod.rs new file mode 100644 index 00000000..76dc4eb4 --- /dev/null +++ b/src/workspace/git/mod.rs @@ -0,0 +1,15 @@ +mod config; +#[cfg(test)] +mod config_tests; +mod discovery; +mod status; +#[cfg(test)] +mod test_support; + +pub use self::{ + discovery::{derive_label_from_cwd, git_branch, git_space_metadata, GitSpaceMetadata}, + status::{git_status_cache_key, git_status_snapshot_for_cwd, GitStatusCacheEntry}, +}; + +#[cfg(test)] +pub(super) use self::status::git_ahead_behind; diff --git a/src/workspace/git/status.rs b/src/workspace/git/status.rs new file mode 100644 index 00000000..24e59e57 --- /dev/null +++ b/src/workspace/git/status.rs @@ -0,0 +1,386 @@ +use std::path::{Path, PathBuf}; + +use crate::workspace::WorkspaceGitStatusSnapshot; + +use super::{ + config::{read_branch_config, upstream_full_ref}, + discovery::{ + canonicalize_best_effort_path, git_branch, git_space_metadata, git_worktree_info, + read_ref_oid, GitWorktreeInfo, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitStatusCacheEntry { + pub fingerprint: GitStatusFingerprint, + pub snapshot: WorkspaceGitStatusSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitStatusFingerprint { + pub git_dir: PathBuf, + pub git_common_dir: PathBuf, + pub head: GitHeadIdentity, + pub upstream: Option<GitUpstreamIdentity>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GitHeadIdentity { + Branch { + full_ref: String, + short_name: String, + oid: Option<String>, + }, + Detached { + oid: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitUpstreamIdentity { + pub remote: String, + pub merge_ref: String, + pub full_ref: String, + pub oid: Option<String>, +} + +pub fn git_status_cache_key(cwd: &Path) -> Option<PathBuf> { + git_worktree_info(cwd).map(|info| canonicalize_best_effort_path(&info.repo_root)) +} + +pub fn git_status_snapshot_for_cwd( + cwd: &Path, + cached: Option<&GitStatusCacheEntry>, +) -> (WorkspaceGitStatusSnapshot, Option<GitStatusCacheEntry>) { + let branch = git_branch(cwd); + let space = git_space_metadata(cwd); + let Some(fingerprint) = git_status_fingerprint(cwd) else { + return ( + WorkspaceGitStatusSnapshot { + branch, + ahead_behind: None, + space, + }, + None, + ); + }; + + if let Some(cached) = cached.filter(|entry| entry.fingerprint == fingerprint) { + let snapshot = WorkspaceGitStatusSnapshot { + branch, + ahead_behind: cached.snapshot.ahead_behind, + space, + }; + return ( + snapshot.clone(), + Some(GitStatusCacheEntry { + fingerprint, + snapshot, + }), + ); + } + + let ahead_behind = fingerprint + .head_oid() + .zip(fingerprint.upstream_oid()) + .and_then(|(head_oid, upstream_oid)| git_ahead_behind_between(cwd, head_oid, upstream_oid)); + let snapshot = WorkspaceGitStatusSnapshot { + branch, + ahead_behind, + space, + }; + ( + snapshot.clone(), + Some(GitStatusCacheEntry { + fingerprint, + snapshot, + }), + ) +} + +pub(super) fn git_status_fingerprint(cwd: &Path) -> Option<GitStatusFingerprint> { + let info = git_worktree_info(cwd)?; + let head = read_head_identity(&info)?; + let upstream = match &head { + GitHeadIdentity::Branch { short_name, .. } => read_upstream_identity(&info, short_name), + GitHeadIdentity::Detached { .. } => 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, + }) +} + +impl GitStatusFingerprint { + fn head_oid(&self) -> Option<&str> { + match &self.head { + GitHeadIdentity::Branch { oid, .. } => oid.as_deref(), + GitHeadIdentity::Detached { oid } => Some(oid.as_str()), + } + } + + fn upstream_oid(&self) -> Option<&str> { + self.upstream + .as_ref() + .and_then(|upstream| upstream.oid.as_deref()) + } +} + +fn read_head_identity(info: &GitWorktreeInfo) -> Option<GitHeadIdentity> { + let head = std::fs::read_to_string(info.git_dir.join("HEAD")).ok()?; + let head = head.trim(); + if let Some(full_ref) = head.strip_prefix("ref: ") { + let short_name = full_ref.strip_prefix("refs/heads/")?.to_string(); + let oid = read_ref_oid(&info.git_common_dir, full_ref); + return Some(GitHeadIdentity::Branch { + full_ref: full_ref.to_string(), + short_name, + oid, + }); + } + + (!head.is_empty()).then(|| GitHeadIdentity::Detached { + oid: head.to_string(), + }) +} + +fn read_upstream_identity(info: &GitWorktreeInfo, branch: &str) -> Option<GitUpstreamIdentity> { + let config = read_branch_config(info, branch)?; + let full_ref = upstream_full_ref(&config)?; + let oid = read_ref_oid(&info.git_common_dir, &full_ref); + Some(GitUpstreamIdentity { + remote: config.remote, + merge_ref: config.merge_ref, + full_ref, + oid, + }) +} + +#[cfg(test)] +pub(crate) fn git_ahead_behind(cwd: &Path) -> Option<(usize, usize)> { + super::discovery::git_repo_root(cwd)?; + + let output = std::process::Command::new("git") + .arg("-C") + .arg(cwd) + .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + parse_git_ahead_behind_output(&stdout) +} + +fn git_ahead_behind_between( + cwd: &Path, + head_oid: &str, + upstream_oid: &str, +) -> Option<(usize, usize)> { + let range = format!("{head_oid}...{upstream_oid}"); + let output = std::process::Command::new("git") + .arg("-C") + .arg(cwd) + .args(["rev-list", "--left-right", "--count", &range]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + parse_git_ahead_behind_output(&stdout) +} + +fn parse_git_ahead_behind_output(stdout: &str) -> Option<(usize, usize)> { + let mut parts = stdout.split_whitespace(); + let ahead = parts.next()?.parse().ok()?; + let behind = parts.next()?.parse().ok()?; + Some((ahead, behind)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace::git::test_support::{run_git, temp_test_dir, write_fake_tracked_repo}; + + #[test] + fn git_status_cache_key_ignores_invalid_git_marker() { + let base = temp_test_dir("invalid-git-root"); + let cwd = base.join("workspace"); + std::fs::create_dir_all(base.join(".git")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + assert_eq!(git_status_cache_key(&cwd), None); + + std::fs::remove_dir_all(base).unwrap(); + } + + #[test] + fn git_status_reuses_cached_ahead_behind_when_fingerprint_matches() { + let root = temp_test_dir("cache-hit"); + write_fake_tracked_repo(&root); + let fingerprint = git_status_fingerprint(&root).unwrap(); + let cached = GitStatusCacheEntry { + fingerprint, + snapshot: WorkspaceGitStatusSnapshot { + branch: Some("main".into()), + ahead_behind: Some((2, 1)), + space: git_space_metadata(&root), + }, + }; + + let (snapshot, update) = git_status_snapshot_for_cwd(&root, Some(&cached)); + + assert_eq!(snapshot.branch.as_deref(), Some("main")); + assert_eq!(snapshot.ahead_behind, Some((2, 1))); + assert_eq!(update.unwrap().snapshot.ahead_behind, Some((2, 1))); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn git_status_does_not_reuse_cache_when_branch_changes_at_same_oid() { + let root = temp_test_dir("branch-switch"); + write_fake_tracked_repo(&root); + let fingerprint = git_status_fingerprint(&root).unwrap(); + let cached = GitStatusCacheEntry { + fingerprint, + snapshot: WorkspaceGitStatusSnapshot { + branch: Some("main".into()), + ahead_behind: Some((4, 0)), + space: git_space_metadata(&root), + }, + }; + std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/feature\n").unwrap(); + std::fs::write( + root.join(".git/refs/heads/feature"), + "1111111111111111111111111111111111111111\n", + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[branch \"feature\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); + + let (snapshot, _) = git_status_snapshot_for_cwd(&root, Some(&cached)); + + assert_eq!(snapshot.branch.as_deref(), Some("feature")); + assert_eq!(snapshot.ahead_behind, None); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn git_status_clears_ahead_behind_when_upstream_is_unset() { + let root = temp_test_dir("upstream-unset"); + write_fake_tracked_repo(&root); + let fingerprint = git_status_fingerprint(&root).unwrap(); + let cached = GitStatusCacheEntry { + fingerprint, + snapshot: WorkspaceGitStatusSnapshot { + branch: Some("main".into()), + ahead_behind: Some((0, 3)), + space: git_space_metadata(&root), + }, + }; + std::fs::write(root.join(".git/config"), "").unwrap(); + + let (snapshot, _) = git_status_snapshot_for_cwd(&root, Some(&cached)); + + assert_eq!(snapshot.branch.as_deref(), Some("main")); + assert_eq!(snapshot.ahead_behind, None); + + 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); + std::fs::remove_file(root.join(".git/refs/remotes/origin/main")).unwrap(); + std::fs::write( + root.join(".git/packed-refs"), + "# pack-refs with: peeled fully-peeled sorted\n2222222222222222222222222222222222222222 refs/remotes/origin/main\n", + ) + .unwrap(); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + assert_eq!( + fingerprint.upstream.unwrap().oid.as_deref(), + Some("2222222222222222222222222222222222222222") + ); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn git_status_cache_key_is_per_linked_worktree_checkout() { + let base = temp_test_dir("linked-worktree-keys"); + let common_dir = base.join("repo/.git"); + let worktree_one = base.join("one"); + let worktree_two = base.join("two"); + let git_dir_one = common_dir.join("worktrees/one"); + let git_dir_two = common_dir.join("worktrees/two"); + std::fs::create_dir_all(&git_dir_one).unwrap(); + std::fs::create_dir_all(&git_dir_two).unwrap(); + std::fs::create_dir_all(&worktree_one).unwrap(); + std::fs::create_dir_all(&worktree_two).unwrap(); + std::fs::write( + worktree_one.join(".git"), + format!("gitdir: {}\n", git_dir_one.display()), + ) + .unwrap(); + std::fs::write( + worktree_two.join(".git"), + format!("gitdir: {}\n", git_dir_two.display()), + ) + .unwrap(); + std::fs::write(git_dir_one.join("HEAD"), "ref: refs/heads/one\n").unwrap(); + std::fs::write(git_dir_two.join("HEAD"), "ref: refs/heads/two\n").unwrap(); + + assert_ne!( + git_status_cache_key(&worktree_one), + git_status_cache_key(&worktree_two) + ); + + std::fs::remove_dir_all(base).unwrap(); + } + + #[test] + fn git_status_recomputes_ahead_behind_when_head_moves() { + let base = temp_test_dir("head-moves"); + let remote = base.join("remote.git"); + let repo = base.join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + let remote_arg = remote.to_string_lossy().to_string(); + run_git(&base, &["init", "--bare", &remote_arg]); + run_git(&repo, &["init"]); + run_git(&repo, &["config", "user.email", "herdr@example.invalid"]); + run_git(&repo, &["config", "user.name", "Herdr Test"]); + run_git(&repo, &["commit", "--allow-empty", "-m", "initial"]); + run_git(&repo, &["branch", "-M", "main"]); + run_git(&repo, &["remote", "add", "origin", &remote_arg]); + run_git(&repo, &["push", "-u", "origin", "main"]); + + let (initial, cache_entry) = git_status_snapshot_for_cwd(&repo, None); + assert_eq!(initial.ahead_behind, Some((0, 0))); + run_git(&repo, &["commit", "--allow-empty", "-m", "ahead"]); + + let (updated, _) = git_status_snapshot_for_cwd(&repo, cache_entry.as_ref()); + + assert_eq!(updated.branch.as_deref(), Some("main")); + assert_eq!(updated.ahead_behind, Some((1, 0))); + + std::fs::remove_dir_all(base).unwrap(); + } +} diff --git a/src/workspace/git/test_support.rs b/src/workspace/git/test_support.rs new file mode 100644 index 00000000..feba4b3a --- /dev/null +++ b/src/workspace/git/test_support.rs @@ -0,0 +1,51 @@ +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(super) fn temp_test_dir(name: &str) -> PathBuf { + let unique = format!( + "herdr-workspace-tests-{}-{}-{}", + name, + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let path = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&path).unwrap(); + path +} + +pub(super) fn write_fake_tracked_repo(root: &Path) { + let head_oid = "1111111111111111111111111111111111111111"; + let upstream_oid = "2222222222222222222222222222222222222222"; + std::fs::create_dir_all(root.join(".git/refs/heads")).unwrap(); + std::fs::create_dir_all(root.join(".git/refs/remotes/origin")).unwrap(); + std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + std::fs::write(root.join(".git/refs/heads/main"), format!("{head_oid}\n")).unwrap(); + std::fs::write( + root.join(".git/refs/remotes/origin/main"), + format!("{upstream_oid}\n"), + ) + .unwrap(); + std::fs::write( + root.join(".git/config"), + "[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n", + ) + .unwrap(); +} + +pub(super) fn run_git(cwd: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .arg("-C") + .arg(cwd) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); +} |
