1use std::fmt::Display;
2use std::ops::Deref;
3
4use camino::Utf8Path;
5use camino::Utf8PathBuf;
6use miette::miette;
7use owo_colors::OwoColorize;
8use owo_colors::Stream;
9use rustc_hash::FxHashMap;
10use winnow::combinator::alt;
11use winnow::combinator::cut_err;
12use winnow::combinator::eof;
13use winnow::combinator::opt;
14use winnow::combinator::repeat_till;
15use winnow::error::AddContext;
16use winnow::error::ContextError;
17use winnow::error::ErrMode;
18use winnow::error::StrContextValue;
19use winnow::stream::Stream as _;
20use winnow::PResult;
21use winnow::Parser;
22
23use crate::git::GitLike;
24use crate::parse::till_null;
25use crate::CommitHash;
26use crate::LocalBranchRef;
27use crate::PathDisplay;
28use crate::Ref;
29use crate::ResolvedCommitish;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Worktrees {
36 pub(crate) main: Utf8PathBuf,
39 pub(crate) inner: FxHashMap<Utf8PathBuf, Worktree>,
41}
42
43impl Worktrees {
44 pub fn main_path(&self) -> &Utf8Path {
45 &self.main
46 }
47
48 pub fn main(&self) -> &Worktree {
49 self.inner.get(&self.main).unwrap()
50 }
51
52 pub fn into_main(mut self) -> Worktree {
53 self.inner.remove(&self.main).unwrap()
54 }
55
56 pub fn into_inner(self) -> FxHashMap<Utf8PathBuf, Worktree> {
57 self.inner
58 }
59
60 pub fn for_branch(&self, branch: &LocalBranchRef) -> Option<&Worktree> {
61 self.iter()
62 .map(|(_path, worktree)| worktree)
63 .find(|worktree| worktree.head.branch() == Some(branch))
64 }
65
66 fn parser(input: &mut &str) -> PResult<Self> {
67 let mut main = Worktree::parser.parse_next(input)?;
68 main.is_main = true;
69 let main_path = main.path.clone();
70
71 let mut inner: FxHashMap<_, _> = repeat_till(
72 0..,
73 Worktree::parser.map(|worktree| (worktree.path.clone(), worktree)),
74 eof,
75 )
76 .map(|(inner, _eof)| inner)
77 .parse_next(input)?;
78
79 inner.insert(main_path.clone(), main);
80
81 let worktrees = Self {
82 main: main_path,
83 inner,
84 };
85
86 tracing::debug!(
87 worktrees=%worktrees,
88 "Parsed worktrees",
89 );
90
91 Ok(worktrees)
92 }
93
94 pub fn parse(git: &impl GitLike, input: &str) -> miette::Result<Self> {
95 let mut ret = Self::parser.parse(input).map_err(|err| miette!("{err}"))?;
96
97 if ret.main().head.is_bare() {
98 let git_dir = git.path().git_common_dir()?;
109 if git_dir != ret.main {
110 let old_main_path = std::mem::replace(&mut ret.main, git_dir);
111 let mut main = ret
112 .inner
113 .remove(&old_main_path)
114 .expect("There is always a main worktree");
115 main.path = ret.main.clone();
116 ret.inner.insert(ret.main.clone(), main);
117 }
118 }
119
120 Ok(ret)
121 }
122}
123
124impl Deref for Worktrees {
125 type Target = FxHashMap<Utf8PathBuf, Worktree>;
126
127 fn deref(&self) -> &Self::Target {
128 &self.inner
129 }
130}
131
132impl IntoIterator for Worktrees {
133 type Item = (Utf8PathBuf, Worktree);
134
135 type IntoIter = std::collections::hash_map::IntoIter<Utf8PathBuf, Worktree>;
136
137 fn into_iter(self) -> Self::IntoIter {
138 self.inner.into_iter()
139 }
140}
141
142impl Display for Worktrees {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 let mut trees = self.values().peekable();
145 while let Some(tree) = trees.next() {
146 if trees.peek().is_none() {
147 write!(f, "{tree}")?;
148 } else {
149 writeln!(f, "{tree}")?;
150 }
151 }
152 Ok(())
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum WorktreeHead {
158 Bare,
159 Detached(CommitHash),
160 Branch(CommitHash, LocalBranchRef),
161}
162
163impl WorktreeHead {
164 pub fn commit(&self) -> Option<&CommitHash> {
165 match self {
166 WorktreeHead::Bare => None,
167 WorktreeHead::Detached(commit) => Some(commit),
168 WorktreeHead::Branch(commit, _branch) => Some(commit),
169 }
170 }
171
172 pub fn commitish(&self) -> Option<ResolvedCommitish> {
173 match self {
174 WorktreeHead::Bare => None,
175 WorktreeHead::Detached(commit) => Some(ResolvedCommitish::Commit(commit.clone())),
176 WorktreeHead::Branch(_, branch) => Some(ResolvedCommitish::Ref(branch.deref().clone())),
177 }
178 }
179
180 pub fn branch(&self) -> Option<&LocalBranchRef> {
181 match &self {
182 WorktreeHead::Bare => None,
183 WorktreeHead::Detached(_) => None,
184 WorktreeHead::Branch(_, branch) => Some(branch),
185 }
186 }
187
188 pub fn is_bare(&self) -> bool {
189 matches!(&self, WorktreeHead::Bare)
190 }
191
192 pub fn is_detached(&self) -> bool {
193 matches!(&self, WorktreeHead::Detached(_))
194 }
195
196 pub fn parser(input: &mut &str) -> PResult<Self> {
197 alt(("bare\0".map(|_| Self::Bare), Self::parse_non_bare)).parse_next(input)
198 }
199
200 fn parse_non_bare(input: &mut &str) -> PResult<Self> {
201 let _ = "HEAD ".parse_next(input)?;
202 let head = till_null.and_then(CommitHash::parser).parse_next(input)?;
203 let branch = alt((Self::parse_branch, "detached\0".map(|_| None))).parse_next(input)?;
204
205 Ok(match branch {
206 Some(branch) => Self::Branch(head, branch),
207 None => Self::Detached(head),
208 })
209 }
210
211 fn parse_branch(input: &mut &str) -> PResult<Option<LocalBranchRef>> {
212 let _ = "branch ".parse_next(input)?;
213 let before_branch = input.checkpoint();
214 let ref_name = cut_err(till_null.and_then(Ref::parser))
215 .parse_next(input)?
216 .try_into()
217 .map_err(|_err| {
218 ErrMode::Cut(ContextError::new().add_context(
219 input,
220 &before_branch,
221 winnow::error::StrContext::Expected(StrContextValue::Description(
222 "a branch ref",
223 )),
224 ))
225 })?;
226
227 Ok(Some(ref_name))
228 }
229}
230
231impl Display for WorktreeHead {
232 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233 match self {
234 WorktreeHead::Bare => write!(
235 f,
236 "{}",
237 "bare".if_supports_color(Stream::Stdout, |text| text.dimmed())
238 ),
239 WorktreeHead::Detached(commit) => {
240 write!(
241 f,
242 "{}",
243 commit.if_supports_color(Stream::Stdout, |text| text.cyan())
244 )
245 }
246 WorktreeHead::Branch(_, ref_name) => {
247 write!(
248 f,
249 "{}",
250 ref_name.if_supports_color(Stream::Stdout, |text| text.cyan())
251 )
252 }
253 }
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct Worktree {
260 pub path: Utf8PathBuf,
261 pub head: WorktreeHead,
262 pub is_main: bool,
263 pub locked: Option<String>,
264 pub prunable: Option<String>,
265}
266
267impl Display for Worktree {
268 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269 write!(f, "{} {}", self.path.display_path_cwd(), self.head)?;
270
271 if self.is_main {
272 write!(
273 f,
274 " [{}]",
275 "main".if_supports_color(Stream::Stdout, |text| text.cyan())
276 )?;
277 }
278
279 if let Some(reason) = &self.locked {
280 if reason.is_empty() {
281 write!(f, " (locked)")?;
282 } else {
283 write!(f, " (locked: {reason})")?;
284 }
285 }
286
287 if let Some(reason) = &self.prunable {
288 if reason.is_empty() {
289 write!(f, " (prunable)")?;
290 } else {
291 write!(f, " (prunable: {reason})")?;
292 }
293 }
294
295 Ok(())
296 }
297}
298
299impl Worktree {
300 fn parser(input: &mut &str) -> PResult<Self> {
301 let _ = "worktree ".parse_next(input)?;
302 let path = Utf8PathBuf::from(till_null.parse_next(input)?);
303 let head = WorktreeHead::parser.parse_next(input)?;
304 let locked = opt(Self::parse_locked).parse_next(input)?;
305 let prunable = opt(Self::parse_prunable).parse_next(input)?;
306 let _ = '\0'.parse_next(input)?;
307
308 Ok(Self {
309 path,
310 head,
311 locked,
312 prunable,
313 is_main: false,
314 })
315 }
316
317 #[cfg(test)]
318 pub fn new_bare(path: impl Into<Utf8PathBuf>) -> Self {
319 Self {
320 path: path.into(),
321 head: WorktreeHead::Bare,
322 is_main: true,
323 locked: None,
324 prunable: None,
325 }
326 }
327
328 #[cfg(test)]
329 pub fn new_detached(path: impl Into<Utf8PathBuf>, commit: impl Into<CommitHash>) -> Self {
330 Self {
331 path: path.into(),
332 head: WorktreeHead::Detached(commit.into()),
333 is_main: false,
334 locked: None,
335 prunable: None,
336 }
337 }
338
339 #[cfg(test)]
340 pub fn new_branch(
341 path: impl Into<Utf8PathBuf>,
342 commit: impl Into<CommitHash>,
343 branch: impl Into<LocalBranchRef>,
344 ) -> Self {
345 Self {
346 path: path.into(),
347 head: WorktreeHead::Branch(commit.into(), branch.into()),
348 is_main: false,
349 locked: None,
350 prunable: None,
351 }
352 }
353
354 #[cfg(test)]
355 pub fn with_is_main(mut self, is_main: bool) -> Self {
356 self.is_main = is_main;
357 self
358 }
359
360 #[cfg(test)]
361 pub fn with_locked(mut self, locked: impl Into<String>) -> Self {
362 self.locked = Some(locked.into());
363 self
364 }
365
366 #[cfg(test)]
367 pub fn with_prunable(mut self, prunable: impl Into<String>) -> Self {
368 self.prunable = Some(prunable.into());
369 self
370 }
371
372 fn parse_locked(input: &mut &str) -> PResult<String> {
373 let _ = "locked".parse_next(input)?;
374 let reason = Self::parse_reason.parse_next(input)?;
375
376 Ok(reason)
377 }
378
379 fn parse_prunable(input: &mut &str) -> PResult<String> {
380 let _ = "prunable".parse_next(input)?;
381 let reason = Self::parse_reason.parse_next(input)?;
382
383 Ok(reason)
384 }
385
386 fn parse_reason(input: &mut &str) -> PResult<String> {
387 let maybe_space = opt(' ').parse_next(input)?;
388
389 match maybe_space {
390 None => {
391 let _ = '\0'.parse_next(input)?;
392 Ok(String::new())
393 }
394 Some(_) => {
395 let reason = till_null.parse_next(input)?;
396 Ok(reason.into())
397 }
398 }
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use indoc::indoc;
405 use itertools::Itertools;
406 use pretty_assertions::assert_eq;
407
408 use super::*;
409
410 #[test]
411 fn test_parse_worktrees_list() {
412 let worktrees = Worktrees::parser
413 .parse(
414 &indoc!(
415 "
416 worktree /path/to/bare-source
417 bare
418
419 worktree /Users/wiggles/cabal/accept
420 HEAD 0685cb3fec8b7144f865638cfd16768e15125fc2
421 branch refs/heads/rebeccat/fix-accept-flag
422
423 worktree /Users/wiggles/lix
424 HEAD 0d484aa498b3c839991d11afb31bc5fcf368493d
425 detached
426
427 worktree /path/to/linked-worktree-locked-no-reason
428 HEAD 5678abc5678abc5678abc5678abc5678abc5678c
429 branch refs/heads/locked-no-reason
430 locked
431
432 worktree /path/to/linked-worktree-locked-with-reason
433 HEAD 3456def3456def3456def3456def3456def3456b
434 branch refs/heads/locked-with-reason
435 locked reason why is locked
436
437 worktree /path/to/linked-worktree-prunable
438 HEAD 1233def1234def1234def1234def1234def1234b
439 detached
440 prunable gitdir file points to non-existent location
441
442 "
443 )
444 .replace('\n', "\0"),
445 )
446 .unwrap();
447
448 assert_eq!(worktrees.main_path(), "/path/to/bare-source");
449
450 let worktrees = worktrees
451 .inner
452 .into_values()
453 .sorted_by_key(|worktree| worktree.path.to_owned())
454 .collect::<Vec<_>>();
455
456 assert_eq!(
457 worktrees,
458 vec![
459 Worktree::new_branch(
460 "/Users/wiggles/cabal/accept",
461 "0685cb3fec8b7144f865638cfd16768e15125fc2",
462 "rebeccat/fix-accept-flag"
463 ),
464 Worktree::new_detached(
465 "/Users/wiggles/lix",
466 "0d484aa498b3c839991d11afb31bc5fcf368493d"
467 ),
468 Worktree::new_bare("/path/to/bare-source"),
469 Worktree::new_branch(
470 "/path/to/linked-worktree-locked-no-reason",
471 "5678abc5678abc5678abc5678abc5678abc5678c",
472 "locked-no-reason"
473 )
474 .with_locked(""),
475 Worktree::new_branch(
476 "/path/to/linked-worktree-locked-with-reason",
477 "3456def3456def3456def3456def3456def3456b",
478 "locked-with-reason"
479 )
480 .with_locked("reason why is locked"),
481 Worktree::new_detached(
482 "/path/to/linked-worktree-prunable",
483 "1233def1234def1234def1234def1234def1234b",
484 )
485 .with_prunable("gitdir file points to non-existent location"),
486 ]
487 );
488 }
489}