Skip to main content

zeroclaw_runtime/rpc/
git.rs

1//! Pure-filesystem git branch lookup. No `git` shell-out.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6/// Resolved HEAD state for a working tree: the branch name (when on a branch)
7/// and the short commit hash. Either field may be `None` — e.g. a freshly
8/// `git init`'d repo with no commits has a branch but no hash, and a detached
9/// HEAD has a hash but no branch.
10#[derive(Debug, Clone, Default, PartialEq, Eq)]
11pub struct HeadInfo {
12    pub branch: Option<String>,
13    pub hash: Option<String>,
14}
15
16/// Branch name, short SHA for detached HEAD, or `None` outside a git repo.
17pub fn branch_for(start: &Path) -> Option<String> {
18    let info = head_info(start)?;
19    info.branch.or(info.hash)
20}
21
22/// Branch name and short commit hash for the repo containing `start`, or
23/// `None` outside a git repo. On a branch, both fields are populated when the
24/// ref resolves to a commit. Detached HEAD yields `branch: None` with the hash.
25pub 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
52/// Resolve a full refname (e.g. `refs/heads/main`) to its short commit hash,
53/// checking the loose ref file first, then `packed-refs`. Worktrees keep their
54/// own `HEAD` but share refs through the common git dir (`commondir`), so loose
55/// and packed lookups resolve against that shared dir. Returns `None` when the
56/// ref has no commit yet (unborn branch) or cannot be read.
57fn 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
77/// The shared git directory for `git_dir`. For a linked worktree, `git_dir` is
78/// `.git/worktrees/<name>` and the `commondir` file points (often relatively)
79/// at the main `.git`. For a normal repo there is no `commondir` file and the
80/// dir is itself the common dir.
81fn 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        // Shared refs live in the main .git, reachable via the commondir pointer.
193        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}