aboutsummaryrefslogtreecommitdiff
path: root/src/workspace
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-30 23:58:15 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-30 23:58:34 +0300
commitfc4e1bbf6b0fa2ec37445a5b72356514c7c6be6a (patch)
tree93af04af44a4dcc062a7886c51d70641c1903144 /src/workspace
parentd477341e6af816ff4396042b084a0378ef437b4a (diff)
perf: reduce sidebar git polling overhead
refs #353
Diffstat (limited to 'src/workspace')
-rw-r--r--src/workspace/git/config.rs659
-rw-r--r--src/workspace/git/config_tests.rs733
-rw-r--r--src/workspace/git/discovery.rs (renamed from src/workspace/git.rs)146
-rw-r--r--src/workspace/git/mod.rs15
-rw-r--r--src/workspace/git/status.rs386
-rw-r--r--src/workspace/git/test_support.rs51
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 &section {
+ 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 &section {
+ 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 &section {
+ 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 &section {
+ 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 = &section[..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(&current)
+ .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)
+ );
+}