diff options
| author | Lane <150401101+LaneBirmingham@users.noreply.github.com> | 2026-06-03 05:23:51 +1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-02 22:23:51 +0300 |
| commit | a0b124c3822023655206aaaef5a32c37d3ac18a4 (patch) | |
| tree | 23a4814e04bb740bb9d9e29bcd5899ff21ad1c3c /src | |
| parent | b144f608d682f3aa518b50deebf1e5b66adfd132 (diff) | |
fix: resolve git branch names in reftable repos (#423)
* fix: resolve git branch names in reftable repos
refs #384
* fix: limit git plumbing to reftable refs
refs #384
---------
Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/workspace/git/discovery.rs | 126 | ||||
| -rw-r--r-- | src/workspace/git/status.rs | 72 |
2 files changed, 194 insertions, 4 deletions
diff --git a/src/workspace/git/discovery.rs b/src/workspace/git/discovery.rs index de4b090c..7e973bb9 100644 --- a/src/workspace/git/discovery.rs +++ b/src/workspace/git/discovery.rs @@ -111,6 +111,11 @@ fn git_common_dir_for_git_dir(git_dir: &Path) -> PathBuf { 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)?; + let git_common_dir = git_common_dir_for_git_dir(&git_dir); + if git_ref_storage_is_reftable(&git_common_dir) { + return git_symbolic_head_short(&repo_root); + } + let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?; parse_git_head_branch(&head) } @@ -131,11 +136,86 @@ pub(super) fn git_dir_for_repo_root(repo_root: &Path) -> Option<PathBuf> { }) } +pub(super) fn git_symbolic_head_full(repo_root: &Path) -> Option<String> { + git_trimmed_stdout(repo_root, &["symbolic-ref", "--quiet", "HEAD"]) +} + +fn git_symbolic_head_short(repo_root: &Path) -> Option<String> { + git_trimmed_stdout(repo_root, &["symbolic-ref", "--quiet", "--short", "HEAD"]) +} + +pub(super) fn git_rev_parse_verify(repo_root: &Path, revision: &str) -> Option<String> { + git_trimmed_stdout(repo_root, &["rev-parse", "--verify", revision]) +} + +pub(super) fn git_ref_storage_is_reftable(git_common_dir: &Path) -> bool { + read_git_config_value(&git_common_dir.join("config"), "extensions", "refstorage") + .is_some_and(|value| value.eq_ignore_ascii_case("reftable")) +} + fn parse_git_head_branch(head: &str) -> Option<String> { let branch = head.trim().strip_prefix("ref: refs/heads/")?; (!branch.is_empty()).then(|| branch.to_string()) } +fn read_git_config_value(path: &Path, section: &str, key: &str) -> Option<String> { + let contents = std::fs::read_to_string(path).ok()?; + let mut in_section = false; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if let Some(section_name) = simple_git_config_section(line) { + in_section = section_name.eq_ignore_ascii_case(section); + continue; + } + if !in_section { + continue; + } + let Some((name, value)) = line.split_once('=') else { + continue; + }; + if name.trim().eq_ignore_ascii_case(key) { + return Some(strip_git_config_comment(value).trim().to_string()); + } + } + None +} + +fn simple_git_config_section(line: &str) -> Option<&str> { + let section = line.strip_prefix('[')?.split_once(']')?.0.trim(); + (!section.contains('"')).then_some(section) +} + +fn strip_git_config_comment(value: &str) -> &str { + let value = value.trim(); + for marker in ['#', ';'] { + if let Some((prefix, _)) = value.split_once(marker) { + if prefix.chars().next_back().is_some_and(char::is_whitespace) { + return prefix; + } + } + } + value +} + +fn git_trimmed_stdout(repo_root: &Path, args: &[&str]) -> Option<String> { + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo_root) + .args(args) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + let stdout = stdout.trim(); + (!stdout.is_empty()).then(|| stdout.to_string()) +} + pub(super) fn git_repo_root(start: &Path) -> Option<PathBuf> { let mut current = if start.is_dir() { start.to_path_buf() @@ -187,6 +267,7 @@ mod tests { use std::time::{SystemTime, UNIX_EPOCH}; use super::*; + use crate::workspace::git::test_support::run_git; fn temp_test_dir(name: &str) -> PathBuf { let unique = format!( @@ -239,6 +320,24 @@ mod tests { } #[test] + fn git_branch_reads_symbolic_head_from_reftable_repo() { + let root = temp_test_dir("reftable-branch"); + let root_arg = root.to_string_lossy().to_string(); + let output = std::process::Command::new("git") + .args(["init", "--ref-format=reftable", "-b", "main", &root_arg]) + .output() + .unwrap(); + if !output.status.success() { + std::fs::remove_dir_all(root).unwrap(); + return; + } + + assert_eq!(git_branch(&root).as_deref(), Some("main")); + + 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"); @@ -275,4 +374,31 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + + #[test] + fn git_rev_parse_verify_reads_reftable_refs() { + let root = temp_test_dir("reftable-ref-oid"); + let root_arg = root.to_string_lossy().to_string(); + let output = std::process::Command::new("git") + .args(["init", "--ref-format=reftable", "-b", "main", &root_arg]) + .output() + .unwrap(); + if !output.status.success() { + std::fs::remove_dir_all(root).unwrap(); + return; + } + + run_git(&root, &["config", "user.email", "herdr@example.invalid"]); + run_git(&root, &["config", "user.name", "Herdr Test"]); + run_git(&root, &["commit", "--allow-empty", "-m", "initial"]); + + let head_oid = git_rev_parse_verify(&root, "HEAD").unwrap(); + + assert_eq!( + git_rev_parse_verify(&root, "refs/heads/main").as_deref(), + Some(head_oid.as_str()) + ); + + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/src/workspace/git/status.rs b/src/workspace/git/status.rs index 24e59e57..024ffdde 100644 --- a/src/workspace/git/status.rs +++ b/src/workspace/git/status.rs @@ -5,7 +5,8 @@ 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, + canonicalize_best_effort_path, git_branch, git_ref_storage_is_reftable, + git_rev_parse_verify, git_space_metadata, git_symbolic_head_full, git_worktree_info, read_ref_oid, GitWorktreeInfo, }, }; @@ -52,18 +53,18 @@ 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, + branch: git_branch(cwd), ahead_behind: None, space, }, None, ); }; + let branch = fingerprint.branch_name().map(str::to_string); if let Some(cached) = cached.filter(|entry| entry.fingerprint == fingerprint) { let snapshot = WorkspaceGitStatusSnapshot { @@ -115,6 +116,13 @@ pub(super) fn git_status_fingerprint(cwd: &Path) -> Option<GitStatusFingerprint> } impl GitStatusFingerprint { + fn branch_name(&self) -> Option<&str> { + match &self.head { + GitHeadIdentity::Branch { short_name, .. } => Some(short_name.as_str()), + GitHeadIdentity::Detached { .. } => None, + } + } + fn head_oid(&self) -> Option<&str> { match &self.head { GitHeadIdentity::Branch { oid, .. } => oid.as_deref(), @@ -130,6 +138,28 @@ impl GitStatusFingerprint { } fn read_head_identity(info: &GitWorktreeInfo) -> Option<GitHeadIdentity> { + if git_ref_storage_is_reftable(&info.git_common_dir) { + return read_head_identity_from_git(info); + } + + read_head_identity_from_files(info) +} + +fn read_head_identity_from_git(info: &GitWorktreeInfo) -> Option<GitHeadIdentity> { + if let Some(full_ref) = git_symbolic_head_full(&info.repo_root) { + let short_name = full_ref.strip_prefix("refs/heads/")?.to_string(); + let oid = git_rev_parse_verify(&info.repo_root, &full_ref); + return Some(GitHeadIdentity::Branch { + full_ref, + short_name, + oid, + }); + } + + git_rev_parse_verify(&info.repo_root, "HEAD").map(|oid| GitHeadIdentity::Detached { oid }) +} + +fn read_head_identity_from_files(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: ") { @@ -150,7 +180,11 @@ fn read_head_identity(info: &GitWorktreeInfo) -> Option<GitHeadIdentity> { 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); + let oid = if git_ref_storage_is_reftable(&info.git_common_dir) { + git_rev_parse_verify(&info.repo_root, &full_ref) + } else { + read_ref_oid(&info.git_common_dir, &full_ref) + }; Some(GitUpstreamIdentity { remote: config.remote, merge_ref: config.merge_ref, @@ -357,6 +391,36 @@ mod tests { } #[test] + fn git_status_fingerprint_reads_reftable_branch_identity() { + let root = temp_test_dir("reftable-fingerprint"); + let root_arg = root.to_string_lossy().to_string(); + let output = std::process::Command::new("git") + .args(["init", "--ref-format=reftable", "-b", "main", &root_arg]) + .output() + .unwrap(); + if !output.status.success() { + std::fs::remove_dir_all(root).unwrap(); + return; + } + run_git(&root, &["config", "user.email", "herdr@example.invalid"]); + run_git(&root, &["config", "user.name", "Herdr Test"]); + run_git(&root, &["commit", "--allow-empty", "-m", "initial"]); + + let fingerprint = git_status_fingerprint(&root).unwrap(); + + assert_eq!( + fingerprint.head, + GitHeadIdentity::Branch { + full_ref: "refs/heads/main".into(), + short_name: "main".into(), + oid: git_rev_parse_verify(&root, "HEAD"), + } + ); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] fn git_status_recomputes_ahead_behind_when_head_moves() { let base = temp_test_dir("head-moves"); let remote = base.join("remote.git"); |
