Skip to main content

wt/git/
porcelain.rs

1//! Pure parsers for `git` porcelain output (spec ยง4 sanctioned subprocess
2//! reads). Kept separate from the I/O so they are unit-testable on fixed input.
3
4use std::path::PathBuf;
5
6/// A worktree as reported by `git worktree list --porcelain`, before any
7/// filesystem checks or enrichment.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub(crate) struct RawWorktree {
10    /// Absolute path of the worktree.
11    pub(crate) path: PathBuf,
12    /// Checked-out commit (hex OID), or `None` for the bare entry.
13    pub(crate) head: Option<String>,
14    /// Branch name (without the `refs/heads/` prefix), or `None` if
15    /// detached/bare.
16    pub(crate) branch: Option<String>,
17    /// Whether this is the bare repository entry.
18    pub(crate) is_bare: bool,
19    /// Whether the worktree has a detached HEAD.
20    pub(crate) is_detached: bool,
21    /// Whether the worktree is locked.
22    pub(crate) is_locked: bool,
23    /// Whether Git considers the worktree prunable.
24    pub(crate) is_prunable: bool,
25    /// Whether this is the main (first) worktree.
26    pub(crate) is_main: bool,
27    /// Whether the worktree's directory is missing on disk. Left `false` by the
28    /// parser; filled in by enumeration, which can touch the filesystem.
29    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
48/// Parses `git worktree list --porcelain` output. The first record is marked as
49/// the main worktree. Missing-directory detection is applied separately (it
50/// requires filesystem access).
51pub(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
114/// Strips the `refs/heads/` prefix from a branch ref.
115fn strip_branch_ref(reference: &str) -> String {
116    reference
117        .strip_prefix("refs/heads/")
118        .unwrap_or(reference)
119        .to_string()
120}
121
122/// One submodule as reported by `git submodule status`. The leading marker is
123/// `' '` (in sync), `'-'` (not initialized), `'+'` (checked-out commit differs
124/// from the index), or `'U'` (merge conflicts).
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub(crate) struct SubmoduleStatus {
127    /// The leading status marker character.
128    pub(crate) state: char,
129    /// The submodule's path, relative to the superproject.
130    pub(crate) path: String,
131}
132
133impl SubmoduleStatus {
134    /// Whether the submodule is not yet initialized (marker `'-'`).
135    pub(crate) fn is_uninitialized(&self) -> bool {
136        self.state == '-'
137    }
138}
139
140/// Parses `git submodule status` output. Each line is
141/// `<marker><sha> <path>[ (<describe>)]`; the marker is the first character and
142/// is *not* separated from the SHA by a space. Lines that do not parse are
143/// skipped.
144pub(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        // After the marker char comes `<sha> <path>[ (<describe>)]`.
152        let rest = chars.as_str();
153        let Some((_sha, after_sha)) = rest.split_once(' ') else {
154            continue;
155        };
156        // Drop a trailing ` (<describe>)` annotation, keeping paths intact.
157        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        // Empty input, a marker with no SHA/path, and a marker+SHA with no path.
261        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}