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(|_| PathBuf::from("."))
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}