git_prole/git/worktree/
parse.rs

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/// A set of Git worktrees.
32///
33/// Exactly one of the worktrees is the main worktree.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Worktrees {
36    /// The path of the main worktree. This contains the common `.git` directory, or, in the
37    /// case of a bare repository, _is_ a `.git` directory.
38    pub(crate) main: Utf8PathBuf,
39    /// A map from worktree paths to worktree information.
40    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            // Git has a bug(?) where `git worktree list` will show the _parent_ of a
99            // bare worktree in a directory named `.git`. Work around it by getting the
100            // `.git` directory manually and patching up the returned value to replace the path of
101            // the main worktree (which is, unfortunately, stored in three separate places).
102            //
103            // It's _possible_ to do this in the parser, but then the parser is no longer a pure
104            // function and the error handling is very annoying.
105            //
106            // See: https://lore.kernel.org/git/8f961645-2b70-4d45-a9f9-72e71c07bc11@app.fastmail.com/T/
107
108            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/// A Git worktree.
258#[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}