majordomus_cli/worktree/
topology.rs1use std::path::{Path, PathBuf};
10
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14use super::error::{Result, WorktreeError};
15use super::git;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
19pub struct WorktreeRecord {
20 pub path: PathBuf,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub head: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub branch: Option<String>,
29 pub bare: bool,
32 pub detached: bool,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub locked: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub prunable: Option<String>,
40}
41
42pub fn parse_porcelain(text: &str) -> Vec<WorktreeRecord> {
59 parse_lines(text.split('\n'))
60}
61
62pub 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 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
123pub 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
135pub 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}