zeroclaw_runtime/rpc/
git.rs1use std::fs;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, Default, PartialEq, Eq)]
11pub struct HeadInfo {
12 pub branch: Option<String>,
13 pub hash: Option<String>,
14}
15
16pub fn branch_for(start: &Path) -> Option<String> {
18 let info = head_info(start)?;
19 info.branch.or(info.hash)
20}
21
22pub fn head_info(start: &Path) -> Option<HeadInfo> {
26 let head_path = find_head(start)?;
27 let git_dir = head_path.parent()?.to_path_buf();
28 let head = fs::read_to_string(&head_path).ok()?;
29 let head = head.trim();
30
31 if let Some(refname) = head.strip_prefix("ref: ") {
32 let name = refname
33 .strip_prefix("refs/heads/")
34 .or_else(|| refname.strip_prefix("refs/tags/"))
35 .or_else(|| refname.strip_prefix("refs/remotes/"))
36 .unwrap_or(refname);
37 let hash = resolve_ref(&git_dir, refname);
38 Some(HeadInfo {
39 branch: Some(name.to_string()),
40 hash,
41 })
42 } else if head.len() >= 7 && head.chars().all(|c| c.is_ascii_hexdigit()) {
43 Some(HeadInfo {
44 branch: None,
45 hash: Some(head[..7].to_string()),
46 })
47 } else {
48 None
49 }
50}
51
52fn resolve_ref(git_dir: &Path, refname: &str) -> Option<String> {
58 let common = common_dir(git_dir);
59 let loose = common.join(refname);
60 if let Ok(sha) = fs::read_to_string(&loose) {
61 return short_hash(sha.trim());
62 }
63 let packed = fs::read_to_string(common.join("packed-refs")).ok()?;
64 for line in packed.lines() {
65 if line.starts_with('#') || line.starts_with('^') {
66 continue;
67 }
68 if let Some((sha, name)) = line.split_once(' ')
69 && name.trim() == refname
70 {
71 return short_hash(sha.trim());
72 }
73 }
74 None
75}
76
77fn common_dir(git_dir: &Path) -> PathBuf {
82 let Ok(pointer) = fs::read_to_string(git_dir.join("commondir")) else {
83 return git_dir.to_path_buf();
84 };
85 let pointer = pointer.trim();
86 let candidate = git_dir.join(pointer);
87 candidate.canonicalize().unwrap_or(candidate)
88}
89
90fn short_hash(sha: &str) -> Option<String> {
91 if sha.len() >= 7 && sha.chars().all(|c| c.is_ascii_hexdigit()) {
92 Some(sha[..7].to_string())
93 } else {
94 None
95 }
96}
97
98fn find_head(start: &Path) -> Option<PathBuf> {
99 for dir in start.ancestors() {
100 let dot_git = dir.join(".git");
101 let Ok(meta) = fs::metadata(&dot_git) else {
102 continue;
103 };
104 if meta.is_dir() {
105 return Some(dot_git.join("HEAD"));
106 }
107 if meta.is_file() {
108 let contents = fs::read_to_string(&dot_git).ok()?;
109 let gitdir = contents.lines().find_map(|l| l.strip_prefix("gitdir: "))?;
110 return Some(PathBuf::from(gitdir.trim()).join("HEAD"));
111 }
112 }
113 None
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use std::fs;
120 use tempfile::TempDir;
121
122 fn write(path: &Path, contents: &str) {
123 if let Some(parent) = path.parent() {
124 fs::create_dir_all(parent).unwrap();
125 }
126 fs::write(path, contents).unwrap();
127 }
128
129 #[test]
130 fn symbolic_ref_returns_branch() {
131 let td = TempDir::new().unwrap();
132 write(&td.path().join(".git/HEAD"), "ref: refs/heads/main\n");
133 assert_eq!(branch_for(td.path()).as_deref(), Some("main"));
134 }
135
136 #[test]
137 fn nested_branch_name_is_preserved() {
138 let td = TempDir::new().unwrap();
139 write(
140 &td.path().join(".git/HEAD"),
141 "ref: refs/heads/feat/some-thing\n",
142 );
143 assert_eq!(branch_for(td.path()).as_deref(), Some("feat/some-thing"));
144 }
145
146 #[test]
147 fn integration_prefix_is_preserved() {
148 let td = TempDir::new().unwrap();
149 write(
150 &td.path().join(".git/HEAD"),
151 "ref: refs/heads/integration/zeroclaw-tui\n",
152 );
153 assert_eq!(
154 branch_for(td.path()).as_deref(),
155 Some("integration/zeroclaw-tui"),
156 );
157 }
158
159 #[test]
160 fn detached_head_returns_short_sha() {
161 let td = TempDir::new().unwrap();
162 write(
163 &td.path().join(".git/HEAD"),
164 "4a8f5970483036c9c3083e8da75bfb4fcfc32911\n",
165 );
166 assert_eq!(branch_for(td.path()).as_deref(), Some("4a8f597"));
167 }
168
169 #[test]
170 fn subdirectory_walks_up() {
171 let td = TempDir::new().unwrap();
172 write(&td.path().join(".git/HEAD"), "ref: refs/heads/master\n");
173 let sub = td.path().join("crates/inner");
174 fs::create_dir_all(&sub).unwrap();
175 assert_eq!(branch_for(&sub).as_deref(), Some("master"));
176 }
177
178 #[test]
179 fn worktree_follows_gitdir_pointer() {
180 let td = TempDir::new().unwrap();
181 let wt_meta = td.path().join(".git/worktrees/feature");
182 write(&wt_meta.join("HEAD"), "ref: refs/heads/feature\n");
183 let wt = td.path().join("wt-checkout");
184 fs::create_dir_all(&wt).unwrap();
185 fs::write(wt.join(".git"), format!("gitdir: {}\n", wt_meta.display())).unwrap();
186 assert_eq!(branch_for(&wt).as_deref(), Some("feature"));
187 }
188
189 #[test]
190 fn worktree_resolves_hash_via_commondir() {
191 let td = TempDir::new().unwrap();
192 write(
194 &td.path().join(".git/refs/heads/feature"),
195 "4a8f5970483036c9c3083e8da75bfb4fcfc32911\n",
196 );
197 let wt_meta = td.path().join(".git/worktrees/feature");
198 write(&wt_meta.join("HEAD"), "ref: refs/heads/feature\n");
199 write(&wt_meta.join("commondir"), "../..\n");
200 let wt = td.path().join("wt-checkout");
201 fs::create_dir_all(&wt).unwrap();
202 fs::write(wt.join(".git"), format!("gitdir: {}\n", wt_meta.display())).unwrap();
203 let info = head_info(&wt).unwrap();
204 assert_eq!(info.branch.as_deref(), Some("feature"));
205 assert_eq!(info.hash.as_deref(), Some("4a8f597"));
206 }
207
208 #[test]
209 fn no_git_returns_none() {
210 let td = TempDir::new().unwrap();
211 assert_eq!(branch_for(td.path()), None);
212 }
213
214 #[test]
215 fn head_info_branch_with_loose_ref_hash() {
216 let td = TempDir::new().unwrap();
217 write(&td.path().join(".git/HEAD"), "ref: refs/heads/main\n");
218 write(
219 &td.path().join(".git/refs/heads/main"),
220 "4a8f5970483036c9c3083e8da75bfb4fcfc32911\n",
221 );
222 let info = head_info(td.path()).unwrap();
223 assert_eq!(info.branch.as_deref(), Some("main"));
224 assert_eq!(info.hash.as_deref(), Some("4a8f597"));
225 }
226
227 #[test]
228 fn head_info_branch_from_packed_refs() {
229 let td = TempDir::new().unwrap();
230 write(&td.path().join(".git/HEAD"), "ref: refs/heads/master\n");
231 write(
232 &td.path().join(".git/packed-refs"),
233 "# pack-refs with: peeled fully-peeled sorted\n\
234 4a8f5970483036c9c3083e8da75bfb4fcfc32911 refs/heads/master\n",
235 );
236 let info = head_info(td.path()).unwrap();
237 assert_eq!(info.branch.as_deref(), Some("master"));
238 assert_eq!(info.hash.as_deref(), Some("4a8f597"));
239 }
240
241 #[test]
242 fn head_info_detached_has_hash_no_branch() {
243 let td = TempDir::new().unwrap();
244 write(
245 &td.path().join(".git/HEAD"),
246 "4a8f5970483036c9c3083e8da75bfb4fcfc32911\n",
247 );
248 let info = head_info(td.path()).unwrap();
249 assert_eq!(info.branch, None);
250 assert_eq!(info.hash.as_deref(), Some("4a8f597"));
251 }
252
253 #[test]
254 fn head_info_unborn_branch_has_no_hash() {
255 let td = TempDir::new().unwrap();
256 write(&td.path().join(".git/HEAD"), "ref: refs/heads/main\n");
257 let info = head_info(td.path()).unwrap();
258 assert_eq!(info.branch.as_deref(), Some("main"));
259 assert_eq!(info.hash, None);
260 }
261}