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}