1use anyhow::{Context, Result, anyhow};
2use git2::Repository;
3use std::path::{Path, PathBuf};
4use tokio::task::spawn_blocking;
5
6#[derive(Debug, Clone)]
8pub struct RepositoryContext {
9 pub selected_worktree: PathBuf,
11 pub main_worktree: Option<PathBuf>,
13 pub common_git_dir: PathBuf,
15 pub storage_root: PathBuf,
17 pub current_worktree_name: Option<String>,
19}
20
21#[derive(Debug, Clone)]
23pub struct WorktreeInfo {
24 pub name: String,
25 pub path: PathBuf,
26 pub branch: Option<String>,
27 pub head_summary: Option<String>,
28 pub is_current: bool,
29}
30
31pub async fn discover_from_cwd() -> Result<RepositoryContext> {
33 let cwd = std::env::current_dir().context("failed to read current working directory")?;
34 discover(&cwd).await
35}
36
37pub async fn discover(start_dir: impl AsRef<Path>) -> Result<RepositoryContext> {
39 let start_dir = start_dir.as_ref().to_path_buf();
40 spawn_blocking(move || discover_sync(&start_dir))
41 .await
42 .context("failed to join repository discovery task")?
43}
44
45fn discover_sync(start_dir: &Path) -> Result<RepositoryContext> {
46 let repo = Repository::discover(start_dir).context("failed to discover git repository")?;
47 let workdir = repo.workdir().map(Path::to_path_buf);
48 let common_git_dir = repo.commondir().to_path_buf();
49
50 let main_worktree = resolve_main_worktree(&common_git_dir, workdir.as_ref());
51
52 let selected_worktree = workdir.clone().unwrap_or_else(|| start_dir.to_path_buf());
53
54 let storage_root = resolve_storage_root(main_worktree.as_ref(), &common_git_dir)?;
55 let current_worktree_name = detect_current_worktree_name(&repo, workdir.as_ref())?;
56
57 Ok(RepositoryContext {
58 selected_worktree,
59 main_worktree,
60 common_git_dir,
61 storage_root,
62 current_worktree_name,
63 })
64}
65
66fn resolve_main_worktree(common_git_dir: &Path, workdir: Option<&PathBuf>) -> Option<PathBuf> {
67 let wd = workdir?;
68 let canonical_common = std::fs::canonicalize(common_git_dir).ok();
69 let canonical_wd = std::fs::canonicalize(wd).ok();
70
71 if let (Some(common), Some(wd_canon)) = (canonical_common.as_deref(), canonical_wd.as_deref()) {
72 let wd_git = wd_canon.join(".git");
73 let wd_git_canon = std::fs::canonicalize(&wd_git).ok();
74 if wd_git_canon.as_deref() == Some(common) {
75 return canonical_wd.clone();
76 }
77 }
78
79 if let Some(parent) = common_git_dir.parent()
80 && parent.is_dir()
81 {
82 return Some(parent.to_path_buf());
83 }
84 canonical_wd.clone()
85}
86
87fn resolve_storage_root(main_worktree: Option<&PathBuf>, common_git_dir: &Path) -> Result<PathBuf> {
88 if let Some(wd) = main_worktree {
89 return Ok(wd.join(".parley"));
90 }
91 Ok(common_git_dir.join("parley"))
92}
93
94fn detect_current_worktree_name(
95 repo: &Repository,
96 workdir: Option<&PathBuf>,
97) -> Result<Option<String>> {
98 let current_path = std::env::current_dir().ok();
99
100 if let Some(wd) = workdir
101 && let Some(current) = current_path.as_deref()
102 {
103 let canonical_current = std::fs::canonicalize(current).ok();
104 let canonical_wd = std::fs::canonicalize(wd).ok();
105 if canonical_current != canonical_wd {
106 let worktrees = repo.worktrees()?;
107 for name in worktrees.iter().flatten() {
108 if let Ok(wt) = repo.find_worktree(name)
109 && let Ok(wt_path) = std::fs::canonicalize(wt.path())
110 && Some(wt_path) == canonical_current
111 {
112 return Ok(Some(name.to_string()));
113 }
114 }
115 return Ok(current.file_name().map(|n| n.to_string_lossy().to_string()));
116 }
117 }
118
119 Ok(None)
120}
121
122pub async fn list_worktrees(start_dir: impl AsRef<Path>) -> Result<Vec<WorktreeInfo>> {
124 let start_dir = start_dir.as_ref().to_path_buf();
125 spawn_blocking(move || list_worktrees_sync(&start_dir))
126 .await
127 .context("failed to join worktree listing task")?
128}
129
130fn list_worktrees_sync(start_dir: &Path) -> Result<Vec<WorktreeInfo>> {
131 let repo = Repository::discover(start_dir).context("failed to discover git repository")?;
132 let current_path = std::env::current_dir().and_then(std::fs::canonicalize).ok();
133
134 let mut result = Vec::new();
135
136 if let Some(workdir) = repo.workdir() {
137 let canonical_wd = std::fs::canonicalize(workdir).ok();
138 let is_current = current_path
139 .as_ref()
140 .and_then(|cp| canonical_wd.as_ref().map(|wd| cp == wd))
141 .unwrap_or(false);
142 let head_summary = repo.head().ok().and_then(|head| {
143 let oid = head.target()?;
144 head.shorthand()
145 .map(|s| format!("{s} ({:.7})", oid.to_string()))
146 });
147 result.push(WorktreeInfo {
148 name: "main".to_string(),
149 path: workdir.to_path_buf(),
150 branch: repo
151 .head()
152 .ok()
153 .and_then(|h| h.shorthand().map(str::to_string)),
154 head_summary,
155 is_current,
156 });
157 }
158
159 let worktrees = repo.worktrees()?;
160 for name in worktrees.iter().flatten() {
161 let Ok(wt) = repo.find_worktree(name) else {
162 continue;
163 };
164 let path = wt.path().to_path_buf();
165 let canonical_path = std::fs::canonicalize(&path).ok();
166 let is_current = current_path
167 .as_ref()
168 .and_then(|cp| canonical_path.as_ref().map(|p| cp == p))
169 .unwrap_or(false);
170
171 let (branch, head_summary) = read_worktree_head(&path);
172
173 result.push(WorktreeInfo {
174 name: name.to_string(),
175 path,
176 branch,
177 head_summary,
178 is_current,
179 });
180 }
181
182 Ok(result)
183}
184
185fn read_worktree_head(path: &Path) -> (Option<String>, Option<String>) {
186 let head_path = path.join(".git").join("HEAD");
187 if !head_path.exists() {
188 let git_file = path.join(".git");
189 if let Ok(content) = std::fs::read_to_string(&git_file) {
190 let git_dir = content.trim().strip_prefix("gitdir: ").map(PathBuf::from);
191 if let Some(git_dir) = git_dir {
192 return parse_head_file(&git_dir.join("HEAD"));
193 }
194 }
195 }
196 parse_head_file(&head_path)
197}
198
199fn parse_head_file(path: &Path) -> (Option<String>, Option<String>) {
200 let content = match std::fs::read_to_string(path) {
201 Ok(c) => c,
202 Err(_) => return (None, None),
203 };
204 let trimmed = content.trim();
205 if let Some(branch) = trimmed.strip_prefix("ref: refs/heads/") {
206 return (Some(branch.to_string()), Some(branch.to_string()));
207 }
208 let short = if trimmed.len() > 7 {
209 &trimmed[..7]
210 } else {
211 trimmed
212 };
213 (None, Some(format!("detached {short}")))
214}
215
216pub async fn resolve_worktree(
218 start_dir: impl AsRef<Path>,
219 name_or_path: &str,
220) -> Result<Option<PathBuf>> {
221 let start_dir = start_dir.as_ref().to_path_buf();
222 let name = name_or_path.to_string();
223 spawn_blocking(move || resolve_worktree_sync(&start_dir, &name))
224 .await
225 .context("failed to join worktree resolution task")?
226}
227
228fn resolve_worktree_sync(start_dir: &Path, name_or_path: &str) -> Result<Option<PathBuf>> {
229 let repo = Repository::discover(start_dir).context("failed to discover git repository")?;
230
231 let worktrees = repo.worktrees()?;
232 for name in worktrees.iter().flatten() {
233 if name == name_or_path
234 && let Ok(wt) = repo.find_worktree(name)
235 {
236 return Ok(Some(wt.path().to_path_buf()));
237 }
238 }
239
240 let candidate = Path::new(name_or_path);
241 if candidate.is_absolute() && candidate.is_dir() {
242 return Ok(Some(candidate.to_path_buf()));
243 }
244
245 let relative = start_dir.join(candidate);
246 if relative.is_dir() {
247 return Ok(Some(relative.canonicalize().unwrap_or(relative)));
248 }
249
250 for name in worktrees.iter().flatten() {
251 let Ok(wt) = repo.find_worktree(name) else {
252 continue;
253 };
254 let wt_path = wt.path();
255 if let Some(file_name) = wt_path.file_name()
256 && file_name == name_or_path
257 {
258 return Ok(Some(wt_path.to_path_buf()));
259 }
260 }
261
262 Ok(None)
263}
264
265pub async fn discover_with_worktree(
267 start_dir: impl AsRef<Path>,
268 worktree: Option<&str>,
269) -> Result<RepositoryContext> {
270 let mut ctx = discover(&start_dir).await?;
271
272 if let Some(wt_name) = worktree {
273 let Some(wt_path) = resolve_worktree(&start_dir, wt_name).await? else {
274 return Err(anyhow!("worktree '{wt_name}' not found"));
275 };
276 ctx.selected_worktree = wt_path;
277 ctx.current_worktree_name = Some(wt_name.to_string());
278 }
279
280 Ok(ctx)
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use git2::Repository;
287 use tempfile::tempdir;
288
289 #[tokio::test]
290 async fn discover_normal_repo_has_main_worktree() -> Result<()> {
291 let tmp = tempdir()?;
292 Repository::init(tmp.path())?;
293 let ctx = discover(tmp.path()).await?;
294 let tmp_canonical = std::fs::canonicalize(tmp.path())?;
295 assert_eq!(std::fs::canonicalize(&ctx.selected_worktree)?, tmp_canonical);
296 assert_eq!(ctx.main_worktree.as_deref().and_then(|p| std::fs::canonicalize(p).ok()), Some(tmp_canonical));
297 Ok(())
298 }
299
300 #[tokio::test]
301 async fn resolve_worktree_returns_none_for_unknown() -> Result<()> {
302 let tmp = tempdir()?;
303 Repository::init(tmp.path())?;
304 let result = resolve_worktree(tmp.path(), "nonexistent").await?;
305 assert!(result.is_none());
306 Ok(())
307 }
308
309 #[tokio::test]
310 async fn resolve_worktree_by_absolute_path() -> Result<()> {
311 let tmp = tempdir()?;
312 Repository::init(tmp.path())?;
313 let result = resolve_worktree(tmp.path(), tmp.path().to_str().unwrap()).await?;
314 assert_eq!(result, Some(tmp.path().to_path_buf()));
315 Ok(())
316 }
317}