Skip to main content

majordomus_cli/worktree/
topology.rs

1//! `git worktree list --porcelain`, parsed once into typed records.
2//!
3//! The porcelain format is the machine format: one record per work tree, one attribute per
4//! line, records separated by a blank line, and no colour, no localisation and no column
5//! alignment in it. Everything downstream — the standing of each worktree, the guard, the
6//! migration plan — reads these records; nothing anywhere else runs `git worktree list` or
7//! looks at its human output.
8
9use std::path::{Path, PathBuf};
10
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14use super::error::{Result, WorktreeError};
15use super::git;
16
17/// One work tree as git registered it.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
19pub struct WorktreeRecord {
20    /// The absolute path git holds for it. It may not exist on disk any more; that is what
21    /// `prunable` reports.
22    pub path: PathBuf,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    /// The commit checked out, when there is one.
25    pub head: Option<String>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    /// The branch, short — `refs/heads/` removed. Absent when detached or bare.
28    pub branch: Option<String>,
29    /// The bare repository itself, which `git worktree list` reports as the first record of
30    /// a bare repository.
31    pub bare: bool,
32    /// HEAD is detached here.
33    pub detached: bool,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    /// Locked, with the reason git recorded — an empty string when it recorded none.
36    pub locked: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    /// Prunable, with git's reason (the work tree is gone, usually).
39    pub prunable: Option<String>,
40}
41
42/// Parse porcelain records from attribute lines. The separator is the caller's: `\n` for
43/// `--porcelain`, `\0` for `--porcelain -z`. A blank attribute ends a record.
44///
45/// Unknown attributes are ignored rather than refused: git adds them over time, and a new
46/// attribute is not a reason to stop answering questions about the ones that exist.
47///
48/// ```
49/// use majordomus_cli::worktree::parse_porcelain;
50/// let text = "worktree /a/foo\nHEAD abc\nbranch refs/heads/main\n\n\
51///             worktree /a/foo-wt/x\nHEAD def\ndetached\nlocked in use\n\n";
52/// let r = parse_porcelain(text);
53/// assert_eq!(r.len(), 2);
54/// assert_eq!(r[0].branch.as_deref(), Some("main"));
55/// assert!(r[1].detached);
56/// assert_eq!(r[1].locked.as_deref(), Some("in use"));
57/// ```
58pub fn parse_porcelain(text: &str) -> Vec<WorktreeRecord> {
59    parse_lines(text.split('\n'))
60}
61
62/// The same, from the NUL-separated form, where a path may contain a newline.
63pub fn parse_porcelain_nul(bytes: &[u8]) -> Vec<WorktreeRecord> {
64    let text = String::from_utf8_lossy(bytes);
65    parse_lines(text.split('\0'))
66}
67
68fn parse_lines<'a>(lines: impl Iterator<Item = &'a str>) -> Vec<WorktreeRecord> {
69    let mut out: Vec<WorktreeRecord> = Vec::new();
70    let mut cur: Option<WorktreeRecord> = None;
71    for raw in lines {
72        // `--porcelain` writes `\n`-terminated lines; under `-z` a stray `\r` cannot appear,
73        // and under `--porcelain` on a Windows shell it can.
74        let line = raw.strip_suffix('\r').unwrap_or(raw);
75        if line.is_empty() {
76            if let Some(r) = cur.take() {
77                out.push(r);
78            }
79            continue;
80        }
81        let (key, value) = match line.split_once(' ') {
82            Some((k, v)) => (k, Some(v)),
83            None => (line, None),
84        };
85        match key {
86            "worktree" => {
87                if let Some(r) = cur.take() {
88                    out.push(r);
89                }
90                cur = Some(WorktreeRecord {
91                    path: PathBuf::from(value.unwrap_or_default()),
92                    head: None,
93                    branch: None,
94                    bare: false,
95                    detached: false,
96                    locked: None,
97                    prunable: None,
98                });
99            }
100            _ => {
101                let Some(r) = cur.as_mut() else { continue };
102                match key {
103                    "HEAD" => r.head = value.map(str::to_string),
104                    "branch" => {
105                        r.branch =
106                            value.map(|v| v.strip_prefix("refs/heads/").unwrap_or(v).to_string())
107                    }
108                    "bare" => r.bare = true,
109                    "detached" => r.detached = true,
110                    "locked" => r.locked = Some(value.unwrap_or_default().to_string()),
111                    "prunable" => r.prunable = Some(value.unwrap_or_default().to_string()),
112                    _ => {}
113                }
114            }
115        }
116    }
117    if let Some(r) = cur {
118        out.push(r);
119    }
120    out
121}
122
123/// Every work tree git has registered for the repository `cwd` is in, read in one
124/// subprocess. `-z` is tried first, because it is the only form that survives a path with a
125/// newline in it; a git too old to know the option answers the line-separated form.
126pub fn read(cwd: &Path) -> Result<Vec<WorktreeRecord>> {
127    let z = git::try_run(cwd, &["worktree", "list", "--porcelain", "-z"])?;
128    if z.status == Some(0) {
129        return Ok(parse_porcelain_nul(&z.stdout));
130    }
131    let plain = git::run(cwd, &["worktree", "list", "--porcelain"])?;
132    Ok(parse_porcelain(&String::from_utf8_lossy(&plain.stdout)))
133}
134
135/// The main work tree of the repository: the record git lists first, which is the primary
136/// checkout of a non-bare repository. A bare repository lists its bare directory there, and
137/// that is refused by name rather than treated as a checkout.
138pub fn primary(records: &[WorktreeRecord], git_common_dir: &Path) -> Result<PathBuf> {
139    match records.first() {
140        Some(r) if r.bare => Err(WorktreeError::BareRepositoryUnsupported {
141            git_dir: r.path.clone(),
142        }),
143        Some(r) => Ok(r.path.clone()),
144        None => Err(WorktreeError::CannotDeterminePrimaryWorktree {
145            git_common_dir: git_common_dir.to_path_buf(),
146        }),
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn a_record_without_a_trailing_blank_line_is_still_a_record() {
156        let r = parse_porcelain("worktree /a/foo\nHEAD abc\nbranch refs/heads/main");
157        assert_eq!(r.len(), 1);
158        assert_eq!(r[0].path, PathBuf::from("/a/foo"));
159        assert_eq!(r[0].branch.as_deref(), Some("main"));
160    }
161
162    #[test]
163    fn locked_and_prunable_without_a_reason_are_still_locked_and_prunable() {
164        let r = parse_porcelain("worktree /a/x\nlocked\nprunable\n\n");
165        assert_eq!(r[0].locked.as_deref(), Some(""));
166        assert_eq!(r[0].prunable.as_deref(), Some(""));
167    }
168
169    #[test]
170    fn a_bare_repository_lists_its_git_dir_first_and_is_refused() {
171        let r = parse_porcelain("worktree /a/foo.git\nbare\n\nworktree /a/wt\nHEAD abc\n\n");
172        assert!(r[0].bare);
173        let e = primary(&r, Path::new("/a/foo.git")).unwrap_err();
174        assert_eq!(e.code(), "BareRepositoryUnsupported");
175    }
176
177    #[test]
178    fn no_records_at_all_is_named_rather_than_guessed() {
179        let e = primary(&[], Path::new("/a/.git")).unwrap_err();
180        assert_eq!(e.code(), "CannotDeterminePrimaryWorktree");
181    }
182
183    #[test]
184    fn an_unknown_attribute_does_not_stop_the_parse() {
185        let r = parse_porcelain(
186            "worktree /a/x\nHEAD abc\nsomething-new value\nbranch refs/heads/b\n\n",
187        );
188        assert_eq!(r[0].branch.as_deref(), Some("b"));
189        assert_eq!(r[0].head.as_deref(), Some("abc"));
190    }
191
192    #[test]
193    fn a_path_with_a_newline_survives_the_nul_form() {
194        let bytes = b"worktree /a/we\nird\0HEAD abc\0\0";
195        let r = parse_porcelain_nul(bytes);
196        assert_eq!(r.len(), 1);
197        assert_eq!(r[0].path, PathBuf::from("/a/we\nird"));
198    }
199
200    #[test]
201    fn a_detached_worktree_carries_no_branch() {
202        let r = parse_porcelain("worktree /a/x\nHEAD abc\ndetached\n\n");
203        assert!(r[0].detached);
204        assert!(r[0].branch.is_none());
205    }
206
207    #[test]
208    fn a_hierarchical_branch_keeps_its_slashes() {
209        let r = parse_porcelain(
210            "worktree /a/foo-wt/feature/x/y\nHEAD abc\nbranch refs/heads/feature/x/y\n\n",
211        );
212        assert_eq!(r[0].branch.as_deref(), Some("feature/x/y"));
213    }
214}