Skip to main content

gobby_code/
git.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum WorktreeKind {
6    Main,
7    Linked,
8    NotGit,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct WorktreeInfo {
13    pub top_level: PathBuf,
14    pub git_dir: Option<PathBuf>,
15    pub git_common_dir: Option<PathBuf>,
16    pub kind: WorktreeKind,
17}
18
19pub fn worktree_info(path: &Path) -> anyhow::Result<WorktreeInfo> {
20    let top_level = match git_output(path, &["rev-parse", "--show-toplevel"]) {
21        Ok(output) => PathBuf::from(output).canonicalize()?,
22        Err(_) => {
23            let root = path
24                .canonicalize()
25                .unwrap_or_else(|_| absolute_fallback(path));
26            return Ok(WorktreeInfo {
27                top_level: root,
28                git_dir: None,
29                git_common_dir: None,
30                kind: WorktreeKind::NotGit,
31            });
32        }
33    };
34
35    let git_dir =
36        PathBuf::from(git_output(path, &["rev-parse", "--absolute-git-dir"])?).canonicalize()?;
37    let common_raw = git_output(path, &["rev-parse", "--git-common-dir"])?;
38    let git_common_dir = resolve_git_path(&top_level, &git_dir, &common_raw)?;
39    let kind = if git_dir == git_common_dir {
40        WorktreeKind::Main
41    } else {
42        WorktreeKind::Linked
43    };
44
45    Ok(WorktreeInfo {
46        top_level,
47        git_dir: Some(git_dir),
48        git_common_dir: Some(git_common_dir),
49        kind,
50    })
51}
52
53fn git_output(path: &Path, args: &[&str]) -> anyhow::Result<String> {
54    let output = Command::new("git")
55        .arg("-C")
56        .arg(path)
57        .args(args)
58        .output()?;
59    if !output.status.success() {
60        anyhow::bail!("git command failed");
61    }
62    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
63}
64
65fn resolve_git_path(top_level: &Path, git_dir: &Path, raw: &str) -> anyhow::Result<PathBuf> {
66    let path = PathBuf::from(raw);
67    if path.is_absolute() {
68        return Ok(path.canonicalize()?);
69    }
70
71    let top_candidate = top_level.join(&path);
72    if top_candidate.exists() {
73        return Ok(top_candidate.canonicalize()?);
74    }
75
76    Ok(git_dir.join(path).canonicalize()?)
77}
78
79fn absolute_fallback(path: &Path) -> PathBuf {
80    if path.is_absolute() {
81        path.to_path_buf()
82    } else {
83        std::env::current_dir()
84            .unwrap_or_else(|_| std::env::temp_dir())
85            .join(path)
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn run_git(dir: &Path, args: &[&str]) {
94        let status = Command::new("git")
95            .arg("-C")
96            .arg(dir)
97            .args(args)
98            .status()
99            .expect("run git");
100        assert!(status.success(), "git {:?} failed", args);
101    }
102
103    fn commit_initial(repo: &Path) {
104        std::fs::write(repo.join("README.md"), "hello\n").expect("write readme");
105        run_git(repo, &["add", "README.md"]);
106        run_git(
107            repo,
108            &[
109                "-c",
110                "user.email=test@example.com",
111                "-c",
112                "user.name=Test User",
113                "commit",
114                "-m",
115                "initial",
116            ],
117        );
118    }
119
120    #[test]
121    fn detects_normal_repo_as_main_worktree() {
122        let tmp = tempfile::tempdir().expect("tempdir");
123        let repo = tmp.path().join("repo");
124        std::fs::create_dir(&repo).expect("create repo");
125        run_git(&repo, &["init"]);
126
127        let info = worktree_info(&repo).expect("worktree info");
128
129        assert_eq!(info.kind, WorktreeKind::Main);
130        assert_eq!(info.top_level, repo.canonicalize().unwrap());
131        assert_eq!(info.git_dir, info.git_common_dir);
132    }
133
134    #[test]
135    fn detects_linked_worktree() {
136        let tmp = tempfile::tempdir().expect("tempdir");
137        let repo = tmp.path().join("repo");
138        let linked = tmp.path().join("linked");
139        std::fs::create_dir(&repo).expect("create repo");
140        run_git(&repo, &["init"]);
141        commit_initial(&repo);
142        run_git(
143            &repo,
144            &[
145                "worktree",
146                "add",
147                "-b",
148                "linked-branch",
149                linked.to_str().unwrap(),
150            ],
151        );
152
153        let info = worktree_info(&linked).expect("worktree info");
154
155        assert_eq!(info.kind, WorktreeKind::Linked);
156        assert_eq!(info.top_level, linked.canonicalize().unwrap());
157        assert_ne!(info.git_dir, info.git_common_dir);
158    }
159
160    #[test]
161    fn separate_git_dir_is_main_worktree() {
162        let tmp = tempfile::tempdir().expect("tempdir");
163        let repo = tmp.path().join("repo");
164        let git_dir = tmp.path().join("separate.git");
165        std::fs::create_dir(&repo).expect("create repo");
166        let status = Command::new("git")
167            .arg("-C")
168            .arg(&repo)
169            .arg("init")
170            .arg("--separate-git-dir")
171            .arg(&git_dir)
172            .status()
173            .expect("run git init");
174        assert!(status.success());
175
176        let info = worktree_info(&repo).expect("worktree info");
177
178        assert_eq!(info.kind, WorktreeKind::Main);
179        assert_eq!(info.git_dir, info.git_common_dir);
180        assert_eq!(info.git_dir, Some(git_dir.canonicalize().unwrap()));
181    }
182}