1use super::fs_kind::{FilesystemKind, filesystem_kind, is_subvolume_root};
20use super::fs_mount_options::read_mount_facts;
21use super::shell_quote::shell_quote;
22use std::os::unix::ffi::OsStrExt;
23use std::path::{Path, PathBuf};
24
25pub const SECTION_MARKER: &str = "git cli-git doctor: filesystem:";
27
28pub const REMOVAL_OPTION: &str = "user_subvol_rm_allowed";
30
31const OSTREE_BOOTED_PATH: &str = "/run/ostree-booted";
33
34const SYSROOT_PATH: &str = "/sysroot";
37
38const CMDLINE_PATH: &str = "/proc/cmdline";
40
41#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct MountSummary {
52 pub mount_point: PathBuf,
54 pub source: String,
56 pub has_removal_option: bool,
58}
59
60#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct FilesystemFacts {
72 pub root: PathBuf,
74 pub kind: FilesystemKind,
76 pub root_is_subvolume: bool,
78 pub mount: Option<MountSummary>,
80 pub root_source: Option<String>,
83 pub ostree_booted: bool,
85 pub kernel_rootflags: Vec<String>,
87}
88
89fn command_line_words(command_line: &str) -> Vec<&str> {
100 let mut words: Vec<&str> = Vec::new();
101 let mut start: Option<usize> = None;
102 let mut in_quote: bool = false;
103 for (index, character) in command_line.char_indices() {
104 if character.is_whitespace() && !in_quote {
105 if let Some(began) = start.take() {
106 words.push(&command_line[began..index]);
107 }
108 continue;
109 }
110 if start.is_none() {
111 start = Some(index);
112 }
113 if character == '"' {
114 in_quote = !in_quote;
115 }
116 }
117 if let Some(began) = start {
118 words.push(&command_line[began..]);
119 }
120 return words;
121}
122
123pub fn kernel_value(raw: &str) -> String {
134 let Some(rest) = raw.strip_prefix('"') else { return raw.to_string() };
135 return rest.strip_suffix('"').unwrap_or(rest).to_string();
136}
137
138pub fn parse_rootflags(command_line: &str) -> Vec<String> {
150 return command_line_words(command_line)
151 .into_iter()
152 .filter_map(|word: &str| {
153 let unwrapped: &str = word.strip_prefix('"').and_then(|inner: &str| { return inner.strip_suffix('"'); }).filter(|inner: &&str| { return !inner.is_empty(); }).unwrap_or(word);
154 return unwrapped.strip_prefix("rootflags=");
155 })
156 .map(str::to_string)
157 .collect();
158}
159
160pub fn gather_facts(root: &Path) -> Option<FilesystemFacts> {
170 let kind: FilesystemKind = filesystem_kind(root).ok()?;
171 let root_is_subvolume: bool = kind == FilesystemKind::Btrfs && is_subvolume_root(root).unwrap_or(false);
172 let mount: Option<MountSummary> = read_mount_facts(root).ok().flatten().map(|facts| {
173 return MountSummary { has_removal_option: facts.has_option(REMOVAL_OPTION), source: facts.source.clone(), mount_point: facts.mount_point };
174 });
175 let ostree_booted: bool = Path::new(OSTREE_BOOTED_PATH).exists();
176 let root_source: Option<String> = read_mount_facts(Path::new(if ostree_booted { SYSROOT_PATH } else { "/" })).ok().flatten().map(|facts| { return facts.source; });
177 let command_line: String = std::fs::read_to_string(CMDLINE_PATH).unwrap_or_default();
178 return Some(FilesystemFacts {
179 root: root.to_path_buf(),
180 kind,
181 root_is_subvolume,
182 mount,
183 root_source,
184 ostree_booted,
185 kernel_rootflags: parse_rootflags(command_line.as_str()),
186 });
187}
188
189fn other_lines(facts: &FilesystemFacts) -> Vec<String> {
191 return vec![format!(
192 "{} {} is on a filesystem other than btrfs or overlayfs. `git worktree add` uses Git's own checkout there, and no faster method applies.",
193 SECTION_MARKER,
194 facts.root.display()
195 )];
196}
197
198fn overlay_lines(facts: &FilesystemFacts) -> Vec<String> {
200 return vec![
201 format!("{} {} is on overlayfs.", SECTION_MARKER, facts.root.display()),
202 String::from(" Cloning a file's data into another file inside the same overlay works, so the wrapper's copy of ignored files uses clones there; a snapshot does not apply, because snapshots need a btrfs subvolume and the btrfs under an overlay cannot be addressed from inside it."),
203 String::from(" `git worktree add` uses Git's own checkout, which was several times slower on an overlay than on btrfs in one measurement (doc/planning/cli-git-btrfs-worktree.md, section Overlayfs)."),
204 ];
205}
206
207fn with_removal_option(raw: &str) -> String {
209 if raw.starts_with('"') && raw.len() > 1 && let Some(inner) = raw.strip_suffix('"') {
210 return format!("{inner},{REMOVAL_OPTION}\"");
211 }
212 return format!("{raw},{REMOVAL_OPTION}");
213}
214
215fn kernel_argument_lines(facts: &FilesystemFacts) -> Vec<String> {
217 return match facts.kernel_rootflags.as_slice() {
218 [old] if kernel_value(old).split(',').any(|option: &str| { return option == REMOVAL_OPTION; }) => vec![format!(
219 " Kept across boots: the running kernel command line already lists `{REMOVAL_OPTION}` in `rootflags=`, so a mount that lacks it was made before that; reboot, and compare with the output of `rpm-ostree kargs`."
220 )],
221 [old] => vec![
222 String::from(" Kept across boots on this ostree host, then reboot (the value is the `rootflags=` of the running kernel command line; compare it with the output of `rpm-ostree kargs` before running):"),
223 format!(
224 " sudo rpm-ostree kargs {} {}",
225 shell_quote(format!("--delete=rootflags={old}").as_bytes()),
226 shell_quote(format!("--append=rootflags={}", with_removal_option(old)).as_bytes())
227 ),
228 String::from(" Use delete and append, not `--replace`: the value contains `=`, and libostree splits each argument at the first `=`, so `--replace=rootflags=...` reads the old value as only the part before the next `=`."),
229 ],
230 [] => vec![String::from(" Kept across boots: this ostree host's kernel command line has no `rootflags=` word, so the mount options come from somewhere else (for example `/etc/fstab`); add the option there.")],
231 _ => vec![String::from(" Kept across boots: this host's kernel command line has several `rootflags=` words, so no single command is printed; edit them with `rpm-ostree kargs --editor`.")],
232 };
233}
234
235fn removal_option_lines(facts: &FilesystemFacts, mount: &MountSummary) -> Vec<String> {
237 let mount_point: &Path = mount.mount_point.as_path();
238 let mut lines: Vec<String> = vec![
239 format!(" The mount {} lacks `{REMOVAL_OPTION}`. Without it, deleting a worktree that is a subvolume works but unlinks its files one by one; with it, one call deletes the subvolume.", mount_point.display()),
240 format!(" Active until the next boot or unmount: sudo mount --options remount,{REMOVAL_OPTION} {}", shell_quote(mount_point.as_os_str().as_bytes())),
241 ];
242 let on_root_filesystem: bool = facts.root_source.as_deref() == Some(mount.source.as_str());
243 if facts.ostree_booted && on_root_filesystem {
244 lines.extend(kernel_argument_lines(facts));
245 } else if facts.ostree_booted {
246 lines.push(format!(" Kept across boots: {} is not the root filesystem, so the kernel command line's `rootflags=` does not apply to it; add the option to its `/etc/fstab` entry or its systemd mount unit, then reboot.", mount_point.display()));
247 } else {
248 lines.push(format!(" Kept across boots on a conventional host: add `{REMOVAL_OPTION}` to the options of this filesystem's entry in /etc/fstab (every btrfs entry that shares its filesystem), then reboot."));
249 }
250 return lines;
251}
252
253fn btrfs_lines(facts: &FilesystemFacts) -> Vec<String> {
255 let mut lines: Vec<String> = Vec::new();
256 if facts.root_is_subvolume {
257 lines.push(format!("{} {} is a btrfs subvolume.", SECTION_MARKER, facts.root.display()));
258 } else {
259 lines.push(format!("{} {} is on btrfs but is an ordinary directory, not a subvolume.", SECTION_MARKER, facts.root.display()));
260 lines.push(String::from(" Making it a subvolume can help with performance: worktrees can then be made from btrfs snapshots, which took a median of 0.35 s to 1.09 s in the design measurements, against 2.1 s to 2.7 s for Git's checkout and 28.35 s with an ignored tree of 72 thousand entries, and share every data block with their source (doc/planning/cli-git-btrfs-worktree.md, section Measurements)."));
261 lines.push(String::from(" This version of the wrapper does not create worktrees from snapshots yet; `git worktree add` still uses Git's checkout. The conversion keeps the original tree until you delete it."));
262 lines.push(String::from(" Stop agent sessions and editors that have their working directory inside the checkout first, then:"));
263 lines.push(String::from(" git cli-git migrate-to-subvolume --dry-run"));
264 lines.push(String::from(" git cli-git migrate-to-subvolume"));
265 }
266 match &facts.mount {
267 Some(mount) if mount.has_removal_option => {
268 lines.push(format!(" The mount {} has `{REMOVAL_OPTION}`: a subvolume worktree is deleted in one call.", mount.mount_point.display()));
269 }
270 Some(mount) => lines.extend(removal_option_lines(facts, mount)),
271 None => lines.push(String::from(" The mount table could not be read, so `user_subvol_rm_allowed` was not checked.")),
272 }
273 return lines;
274}
275
276pub fn render_section(facts: &FilesystemFacts) -> String {
286 let lines: Vec<String> = match facts.kind {
287 FilesystemKind::Btrfs => btrfs_lines(facts),
288 FilesystemKind::Overlayfs => overlay_lines(facts),
289 FilesystemKind::Other => other_lines(facts),
290 };
291 let mut text: String = lines.join("\n");
292 text.push('\n');
293 return text;
294}
295
296pub fn host_section(common_directory: &Path) -> String {
309 if common_directory.file_name().is_none_or(|name: &std::ffi::OsStr| { return name != ".git"; }) {
310 return String::new();
311 }
312 let Some(root) = common_directory.parent() else { return String::new() };
313 return gather_facts(root).map(|facts: FilesystemFacts| { return render_section(&facts); }).unwrap_or_default();
314}
315
316#[cfg(test)]
318#[path = "doctor_filesystem_tests.rs"]
319mod tests;