Skip to main content

git_workty/
worktree.rs

1use crate::git::GitRepo;
2use anyhow::{Context, Result};
3use serde::Serialize;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, Serialize)]
7pub struct Worktree {
8    pub path: PathBuf,
9    pub head: String,
10    pub branch: Option<String>,
11    pub branch_short: Option<String>,
12    pub detached: bool,
13    pub locked: bool,
14    pub prunable: bool,
15}
16
17impl Worktree {
18    pub fn name(&self) -> &str {
19        self.branch_short
20            .as_deref()
21            .or_else(|| self.path.file_name().and_then(|s| s.to_str()))
22            .unwrap_or("unknown")
23    }
24
25    pub fn is_main_worktree(&self, repo: &GitRepo) -> bool {
26        // Simple check: if path matches main repo root
27        // We find main repo root via common dir usually.
28        // Assuming common_dir parent is main root works for standard layouts.
29        repo.common_dir.parent() == Some(&self.path) || check_same_path(&self.path, &repo.root)
30        // Or compare with assumed main root logic
31    }
32}
33
34fn check_same_path(p1: &Path, p2: &Path) -> bool {
35    p1.canonicalize().ok() == p2.canonicalize().ok()
36}
37
38pub fn list_worktrees(repo: &GitRepo) -> Result<Vec<Worktree>> {
39    let git_repo = repo.repo.lock().unwrap();
40    let mut worktrees = Vec::new();
41
42    // 1. Linked Worktrees
43    let worktree_names = git_repo.worktrees().context("Failed to list worktrees")?;
44    for name in worktree_names.iter() {
45        let name = name.unwrap();
46        // git2::Worktree structure
47        let wt = git_repo.find_worktree(name)?;
48        let path = wt.path().to_path_buf();
49
50        let should_prune = wt.is_prunable(None).unwrap_or(false);
51        let is_locked = matches!(wt.is_locked(), Ok(git2::WorktreeLockStatus::Locked(_)));
52
53        // If prunable, we might not be able to open it
54        if should_prune {
55            worktrees.push(Worktree {
56                path,
57                head: String::new(),
58                branch: None,
59                branch_short: None,
60                detached: false,
61                locked: is_locked,
62                prunable: true,
63            });
64            continue;
65        }
66
67        // Try to open repo to get head info
68        // Note: Repository::open on a worktree path opens the worktree context
69        match git2::Repository::open(&path) {
70            Ok(wt_repo) => {
71                let (head, branch, branch_short, detached) = get_repo_head_info(&wt_repo);
72                worktrees.push(Worktree {
73                    path,
74                    head,
75                    branch,
76                    branch_short,
77                    detached,
78                    locked: is_locked,
79                    prunable: false,
80                });
81            }
82            Err(_) => {
83                // Could not open, maybe permissions or broken
84                worktrees.push(Worktree {
85                    path,
86                    head: String::new(),
87                    branch: None,
88                    branch_short: None,
89                    detached: false,
90                    locked: is_locked,
91                    prunable: true, // Treat as broken
92                });
93            }
94        }
95    }
96
97    // 2. Main Worktree
98    // We need to identify the main worktree.
99    // Logic: find common_dir, parent is main worktree.
100    let common_dir = if git_repo.is_worktree() {
101        // If we are in a worktree, path is .../.git/worktrees/name
102        git_repo
103            .path()
104            .parent()
105            .and_then(|p| p.parent())
106            .unwrap_or(git_repo.path())
107    } else {
108        // If we are in main, path is .../.git
109        git_repo.path()
110    };
111
112    let main_path = common_dir.parent().unwrap_or(common_dir);
113
114    // Add Main Worktree if not already added (though main usually not in linked list)
115    // We open main path to verify and get status
116    if let Ok(main_repo) = git2::Repository::open(main_path) {
117        if !worktrees
118            .iter()
119            .any(|w| check_same_path(&w.path, main_path))
120        {
121            let (head, branch, branch_short, detached) = get_repo_head_info(&main_repo);
122            worktrees.push(Worktree {
123                path: main_path.to_path_buf(),
124                head,
125                branch,
126                branch_short,
127                detached,
128                locked: false,
129                prunable: false,
130            });
131        }
132    }
133
134    Ok(worktrees)
135}
136
137fn get_repo_head_info(repo: &git2::Repository) -> (String, Option<String>, Option<String>, bool) {
138    let head_ref = repo.head();
139    match head_ref {
140        Ok(r) => {
141            let head_oid = r.target().map(|o| o.to_string()).unwrap_or_default();
142            let detached = repo.head_detached().unwrap_or(false);
143            let name = r.name().map(|s| s.to_string());
144
145            if detached {
146                (head_oid, None, None, true)
147            } else {
148                let shorthand = r.shorthand().map(|s| s.to_string());
149                (head_oid, name, shorthand, false)
150            }
151        }
152        Err(_) => (String::new(), None, None, false), // empty repo?
153    }
154}
155
156pub fn find_worktree<'a>(worktrees: &'a [Worktree], name: &str) -> Option<&'a Worktree> {
157    worktrees.iter().find(|worktree| {
158        worktree.branch_short.as_deref() == Some(name)
159            || worktree.path.file_name().and_then(|s| s.to_str()) == Some(name)
160    })
161}
162
163pub fn slug_from_branch(branch: &str) -> String {
164    branch
165        .chars()
166        .map(|c| {
167            if c.is_alphanumeric() || c == '-' || c == '_' {
168                c
169            } else {
170                '-'
171            }
172        })
173        .collect::<String>()
174        .trim_matches('-')
175        .to_string()
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_slug_from_branch() {
184        assert_eq!(slug_from_branch("feat/login"), "feat-login");
185        assert_eq!(slug_from_branch("fix/bug-123"), "fix-bug-123");
186        assert_eq!(
187            slug_from_branch("feature/add user auth"),
188            "feature-add-user-auth"
189        );
190    }
191}