git_workty/
worktree.rs

1use crate::git::GitRepo;
2use anyhow::{Context, Result};
3use serde::Serialize;
4use std::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        self.path == repo.root
27    }
28}
29
30pub fn parse_worktree_list(output: &str) -> Vec<Worktree> {
31    let mut worktrees = Vec::new();
32    let mut current: Option<WorktreeBuilder> = None;
33
34    for line in output.lines() {
35        if line.is_empty() {
36            if let Some(builder) = current.take() {
37                if let Some(worktree) = builder.build() {
38                    worktrees.push(worktree);
39                }
40            }
41            continue;
42        }
43
44        if let Some((key, value)) = line.split_once(' ') {
45            let builder = current.get_or_insert_with(WorktreeBuilder::default);
46            match key {
47                "worktree" => builder.path = Some(PathBuf::from(value)),
48                "HEAD" => builder.head = Some(value.to_string()),
49                "branch" => builder.branch = Some(value.to_string()),
50                "detached" => builder.detached = true,
51                "locked" => builder.locked = true,
52                "prunable" => builder.prunable = true,
53                _ => {}
54            }
55        } else {
56            let builder = current.get_or_insert_with(WorktreeBuilder::default);
57            match line {
58                "detached" => builder.detached = true,
59                "locked" => builder.locked = true,
60                "prunable" => builder.prunable = true,
61                "bare" => builder.bare = true,
62                _ => {}
63            }
64        }
65    }
66
67    if let Some(builder) = current {
68        if let Some(worktree) = builder.build() {
69            worktrees.push(worktree);
70        }
71    }
72
73    worktrees
74}
75
76#[derive(Default)]
77struct WorktreeBuilder {
78    path: Option<PathBuf>,
79    head: Option<String>,
80    branch: Option<String>,
81    detached: bool,
82    locked: bool,
83    prunable: bool,
84    bare: bool,
85}
86
87impl WorktreeBuilder {
88    fn build(self) -> Option<Worktree> {
89        let path = self.path?;
90        let head = self.head.unwrap_or_default();
91
92        if self.bare {
93            return None;
94        }
95
96        let branch_short = self
97            .branch
98            .as_ref()
99            .map(|b| b.strip_prefix("refs/heads/").unwrap_or(b).to_string());
100
101        Some(Worktree {
102            path,
103            head,
104            branch: self.branch,
105            branch_short,
106            detached: self.detached,
107            locked: self.locked,
108            prunable: self.prunable,
109        })
110    }
111}
112
113pub fn list_worktrees(repo: &GitRepo) -> Result<Vec<Worktree>> {
114    let output = repo
115        .run_git(&["worktree", "list", "--porcelain"])
116        .context("Failed to list worktrees")?;
117    Ok(parse_worktree_list(&output))
118}
119
120pub fn find_worktree<'a>(worktrees: &'a [Worktree], name: &str) -> Option<&'a Worktree> {
121    worktrees.iter().find(|worktree| {
122        worktree.branch_short.as_deref() == Some(name)
123            || worktree.path.file_name().and_then(|s| s.to_str()) == Some(name)
124    })
125}
126
127pub fn slug_from_branch(branch: &str) -> String {
128    branch
129        .chars()
130        .map(|c| {
131            if c.is_alphanumeric() || c == '-' || c == '_' {
132                c
133            } else {
134                '-'
135            }
136        })
137        .collect::<String>()
138        .trim_matches('-')
139        .to_string()
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_parse_worktree_list_normal() {
148        let output = r#"worktree /home/user/project
149HEAD abc123def456
150branch refs/heads/main
151
152worktree /home/user/.workty/project/feat-login
153HEAD def789abc012
154branch refs/heads/feat/login
155
156"#;
157        let worktrees = parse_worktree_list(output);
158        assert_eq!(worktrees.len(), 2);
159        assert_eq!(worktrees[0].branch_short.as_deref(), Some("main"));
160        assert_eq!(worktrees[1].branch_short.as_deref(), Some("feat/login"));
161        assert!(!worktrees[0].detached);
162    }
163
164    #[test]
165    fn test_parse_worktree_list_detached() {
166        let output = r#"worktree /home/user/project
167HEAD abc123def456
168detached
169
170"#;
171        let worktrees = parse_worktree_list(output);
172        assert_eq!(worktrees.len(), 1);
173        assert!(worktrees[0].detached);
174        assert!(worktrees[0].branch.is_none());
175    }
176
177    #[test]
178    fn test_parse_worktree_list_locked() {
179        let output = r#"worktree /home/user/project
180HEAD abc123def456
181branch refs/heads/main
182locked reason here
183
184"#;
185        let worktrees = parse_worktree_list(output);
186        assert_eq!(worktrees.len(), 1);
187        assert!(worktrees[0].locked);
188    }
189
190    #[test]
191    fn test_parse_worktree_list_bare() {
192        let output = r#"worktree /home/user/project.git
193bare
194
195worktree /home/user/project
196HEAD abc123
197branch refs/heads/main
198
199"#;
200        let worktrees = parse_worktree_list(output);
201        assert_eq!(worktrees.len(), 1);
202        assert_eq!(worktrees[0].branch_short.as_deref(), Some("main"));
203    }
204
205    #[test]
206    fn test_slug_from_branch() {
207        assert_eq!(slug_from_branch("feat/login"), "feat-login");
208        assert_eq!(slug_from_branch("fix/bug-123"), "fix-bug-123");
209        assert_eq!(
210            slug_from_branch("feature/add user auth"),
211            "feature-add-user-auth"
212        );
213    }
214}