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