1use std::path::PathBuf;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
9pub(crate) struct RawWorktree {
10 pub(crate) path: PathBuf,
12 pub(crate) head: Option<String>,
14 pub(crate) branch: Option<String>,
17 pub(crate) is_bare: bool,
19 pub(crate) is_detached: bool,
21 pub(crate) is_locked: bool,
23 pub(crate) is_prunable: bool,
25 pub(crate) is_main: bool,
27 pub(crate) is_missing: bool,
30}
31
32impl RawWorktree {
33 fn new(path: PathBuf) -> Self {
34 RawWorktree {
35 path,
36 head: None,
37 branch: None,
38 is_bare: false,
39 is_detached: false,
40 is_locked: false,
41 is_prunable: false,
42 is_main: false,
43 is_missing: false,
44 }
45 }
46}
47
48pub(crate) fn parse_worktree_list(porcelain: &str) -> Vec<RawWorktree> {
52 let mut result: Vec<RawWorktree> = Vec::new();
53 let mut current: Option<RawWorktree> = None;
54 for line in porcelain.lines() {
55 if line.is_empty() {
56 if let Some(wt) = current.take() {
57 result.push(wt);
58 }
59 continue;
60 }
61 let (key, rest) = match line.split_once(' ') {
62 Some((k, r)) => (k, Some(r)),
63 None => (line, None),
64 };
65 match key {
66 "worktree" => {
67 if let Some(wt) = current.take() {
68 result.push(wt);
69 }
70 current = Some(RawWorktree::new(PathBuf::from(rest.unwrap_or_default())));
71 }
72 "HEAD" => {
73 if let Some(wt) = current.as_mut() {
74 wt.head = rest.map(str::to_string);
75 }
76 }
77 "branch" => {
78 if let Some(wt) = current.as_mut() {
79 wt.branch = rest.map(strip_branch_ref);
80 }
81 }
82 "bare" => {
83 if let Some(wt) = current.as_mut() {
84 wt.is_bare = true;
85 }
86 }
87 "detached" => {
88 if let Some(wt) = current.as_mut() {
89 wt.is_detached = true;
90 }
91 }
92 "locked" => {
93 if let Some(wt) = current.as_mut() {
94 wt.is_locked = true;
95 }
96 }
97 "prunable" => {
98 if let Some(wt) = current.as_mut() {
99 wt.is_prunable = true;
100 }
101 }
102 _ => {}
103 }
104 }
105 if let Some(wt) = current.take() {
106 result.push(wt);
107 }
108 if let Some(first) = result.first_mut() {
109 first.is_main = true;
110 }
111 result
112}
113
114fn strip_branch_ref(reference: &str) -> String {
116 reference
117 .strip_prefix("refs/heads/")
118 .unwrap_or(reference)
119 .to_string()
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
126pub(crate) struct SubmoduleStatus {
127 pub(crate) state: char,
129 pub(crate) path: String,
131}
132
133impl SubmoduleStatus {
134 pub(crate) fn is_uninitialized(&self) -> bool {
136 self.state == '-'
137 }
138}
139
140pub(crate) fn parse_submodule_status(output: &str) -> Vec<SubmoduleStatus> {
145 let mut result = Vec::new();
146 for line in output.lines() {
147 let mut chars = line.chars();
148 let Some(state) = chars.next() else {
149 continue;
150 };
151 let rest = chars.as_str();
153 let Some((_sha, after_sha)) = rest.split_once(' ') else {
154 continue;
155 };
156 let path = match after_sha.rfind(" (") {
158 Some(i) => &after_sha[..i],
159 None => after_sha,
160 }
161 .trim();
162 if path.is_empty() {
163 continue;
164 }
165 result.push(SubmoduleStatus {
166 state,
167 path: path.to_string(),
168 });
169 }
170 result
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn parses_main_and_linked() {
179 let input = "worktree /repo\nHEAD aaa111\nbranch refs/heads/main\n\
180 \n\
181 worktree /repo.worktrees/feat\nHEAD bbb222\nbranch refs/heads/feature/x\n\n";
182 let wts = parse_worktree_list(input);
183 assert_eq!(wts.len(), 2);
184 assert_eq!(wts[0].path, PathBuf::from("/repo"));
185 assert_eq!(wts[0].branch.as_deref(), Some("main"));
186 assert_eq!(wts[0].head.as_deref(), Some("aaa111"));
187 assert!(wts[0].is_main);
188 assert_eq!(wts[1].path, PathBuf::from("/repo.worktrees/feat"));
189 assert_eq!(wts[1].branch.as_deref(), Some("feature/x"));
190 assert!(!wts[1].is_main);
191 }
192
193 #[test]
194 fn parses_detached_and_bare_and_locked_and_prunable() {
195 let input = "worktree /bare\nbare\n\
196 \n\
197 worktree /d\nHEAD ccc333\ndetached\n\
198 \n\
199 worktree /l\nHEAD ddd\nbranch refs/heads/x\nlocked being used\n\
200 \n\
201 worktree /p\nHEAD eee\nbranch refs/heads/y\nprunable gitdir gone\n\n";
202 let wts = parse_worktree_list(input);
203 assert_eq!(wts.len(), 4);
204 assert!(wts[0].is_bare && wts[0].is_main);
205 assert!(wts[0].branch.is_none() && wts[0].head.is_none());
206 assert!(wts[1].is_detached);
207 assert!(wts[1].branch.is_none());
208 assert!(wts[2].is_locked);
209 assert_eq!(wts[2].branch.as_deref(), Some("x"));
210 assert!(wts[3].is_prunable);
211 }
212
213 #[test]
214 fn handles_trailing_record_without_blank_line() {
215 let input = "worktree /only\nHEAD f00\nbranch refs/heads/main";
216 let wts = parse_worktree_list(input);
217 assert_eq!(wts.len(), 1);
218 assert_eq!(wts[0].branch.as_deref(), Some("main"));
219 }
220
221 #[test]
222 fn handles_paths_with_spaces() {
223 let input = "worktree /my repo/wt\nHEAD a1\nbranch refs/heads/main\n";
224 let wts = parse_worktree_list(input);
225 assert_eq!(wts[0].path, PathBuf::from("/my repo/wt"));
226 }
227
228 #[test]
229 fn empty_input_yields_no_worktrees() {
230 assert!(parse_worktree_list("").is_empty());
231 }
232
233 #[test]
234 fn parses_submodule_status_markers() {
235 let input = "-aaa111 libs/uninit\n cccddd libs/ok (heads/main)\n\
236 +bbb222 vendor/drift (v1.2-3-gabcdef)\nUeee444 vendor/conflict\n";
237 let subs = parse_submodule_status(input);
238 assert_eq!(subs.len(), 4);
239 assert_eq!(subs[0].state, '-');
240 assert_eq!(subs[0].path, "libs/uninit");
241 assert!(subs[0].is_uninitialized());
242 assert_eq!(subs[1].state, ' ');
243 assert_eq!(subs[1].path, "libs/ok");
244 assert!(!subs[1].is_uninitialized());
245 assert_eq!(subs[2].state, '+');
246 assert_eq!(subs[2].path, "vendor/drift");
247 assert_eq!(subs[3].state, 'U');
248 assert_eq!(subs[3].path, "vendor/conflict");
249 }
250
251 #[test]
252 fn submodule_status_keeps_paths_with_spaces() {
253 let subs = parse_submodule_status("-deadbeef my libs/sub\n");
254 assert_eq!(subs.len(), 1);
255 assert_eq!(subs[0].path, "my libs/sub");
256 }
257
258 #[test]
259 fn submodule_status_skips_unparseable_lines() {
260 assert!(parse_submodule_status("").is_empty());
262 assert!(parse_submodule_status("-\n").is_empty());
263 assert!(parse_submodule_status("-onlysha\n").is_empty());
264 }
265}