Skip to main content

parley/git/
worktree.rs

1use anyhow::{Context, Result, anyhow};
2use git2::Repository;
3use std::path::{Path, PathBuf};
4use tokio::task::spawn_blocking;
5
6/// Shared repository context that accounts for git worktrees.
7#[derive(Debug, Clone)]
8pub struct RepositoryContext {
9    /// The worktree path that git operations should run against.
10    pub selected_worktree: PathBuf,
11    /// The main/original worktree path (where `.parley` lives for normal repos).
12    pub main_worktree: Option<PathBuf>,
13    /// The common git directory (where `commondir` points for worktrees).
14    pub common_git_dir: PathBuf,
15    /// The resolved canonical storage root.
16    pub storage_root: PathBuf,
17    /// Identity of the currently selected worktree.
18    pub current_worktree_name: Option<String>,
19}
20
21/// Information about a single git worktree.
22#[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
31/// Discover repository context from the current working directory.
32pub 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
37/// Discover repository context starting from a given directory.
38pub 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
122/// List all worktrees for the repository containing `start_dir`.
123pub 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
216/// Resolve a worktree selection by name or path against the repository.
217pub 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
265/// Build a `RepositoryContext` with an explicit worktree selection.
266pub 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}