use super::fs_kind::{FilesystemKind, filesystem_kind, is_subvolume_root};
use super::fs_mount_options::read_mount_facts;
use super::shell_quote::shell_quote;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
pub const SECTION_MARKER: &str = "git cli-git doctor: filesystem:";
pub const REMOVAL_OPTION: &str = "user_subvol_rm_allowed";
const OSTREE_BOOTED_PATH: &str = "/run/ostree-booted";
const SYSROOT_PATH: &str = "/sysroot";
const CMDLINE_PATH: &str = "/proc/cmdline";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MountSummary {
pub mount_point: PathBuf,
pub source: String,
pub has_removal_option: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FilesystemFacts {
pub root: PathBuf,
pub kind: FilesystemKind,
pub root_is_subvolume: bool,
pub mount: Option<MountSummary>,
pub root_source: Option<String>,
pub ostree_booted: bool,
pub kernel_rootflags: Vec<String>,
}
fn command_line_words(command_line: &str) -> Vec<&str> {
let mut words: Vec<&str> = Vec::new();
let mut start: Option<usize> = None;
let mut in_quote: bool = false;
for (index, character) in command_line.char_indices() {
if character.is_whitespace() && !in_quote {
if let Some(began) = start.take() {
words.push(&command_line[began..index]);
}
continue;
}
if start.is_none() {
start = Some(index);
}
if character == '"' {
in_quote = !in_quote;
}
}
if let Some(began) = start {
words.push(&command_line[began..]);
}
return words;
}
pub fn kernel_value(raw: &str) -> String {
let Some(rest) = raw.strip_prefix('"') else { return raw.to_string() };
return rest.strip_suffix('"').unwrap_or(rest).to_string();
}
pub fn parse_rootflags(command_line: &str) -> Vec<String> {
return command_line_words(command_line)
.into_iter()
.filter_map(|word: &str| {
let unwrapped: &str = word.strip_prefix('"').and_then(|inner: &str| { return inner.strip_suffix('"'); }).filter(|inner: &&str| { return !inner.is_empty(); }).unwrap_or(word);
return unwrapped.strip_prefix("rootflags=");
})
.map(str::to_string)
.collect();
}
pub fn gather_facts(root: &Path) -> Option<FilesystemFacts> {
let kind: FilesystemKind = filesystem_kind(root).ok()?;
let root_is_subvolume: bool = kind == FilesystemKind::Btrfs && is_subvolume_root(root).unwrap_or(false);
let mount: Option<MountSummary> = read_mount_facts(root).ok().flatten().map(|facts| {
return MountSummary { has_removal_option: facts.has_option(REMOVAL_OPTION), source: facts.source.clone(), mount_point: facts.mount_point };
});
let ostree_booted: bool = Path::new(OSTREE_BOOTED_PATH).exists();
let root_source: Option<String> = read_mount_facts(Path::new(if ostree_booted { SYSROOT_PATH } else { "/" })).ok().flatten().map(|facts| { return facts.source; });
let command_line: String = std::fs::read_to_string(CMDLINE_PATH).unwrap_or_default();
return Some(FilesystemFacts {
root: root.to_path_buf(),
kind,
root_is_subvolume,
mount,
root_source,
ostree_booted,
kernel_rootflags: parse_rootflags(command_line.as_str()),
});
}
fn other_lines(facts: &FilesystemFacts) -> Vec<String> {
return vec![format!(
"{} {} is on a filesystem other than btrfs or overlayfs. `git worktree add` uses Git's own checkout there, and no faster method applies.",
SECTION_MARKER,
facts.root.display()
)];
}
fn overlay_lines(facts: &FilesystemFacts) -> Vec<String> {
return vec![
format!("{} {} is on overlayfs.", SECTION_MARKER, facts.root.display()),
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."),
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)."),
];
}
fn with_removal_option(raw: &str) -> String {
if raw.starts_with('"') && raw.len() > 1 && let Some(inner) = raw.strip_suffix('"') {
return format!("{inner},{REMOVAL_OPTION}\"");
}
return format!("{raw},{REMOVAL_OPTION}");
}
fn kernel_argument_lines(facts: &FilesystemFacts) -> Vec<String> {
return match facts.kernel_rootflags.as_slice() {
[old] if kernel_value(old).split(',').any(|option: &str| { return option == REMOVAL_OPTION; }) => vec![format!(
" 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`."
)],
[old] => vec![
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):"),
format!(
" sudo rpm-ostree kargs {} {}",
shell_quote(format!("--delete=rootflags={old}").as_bytes()),
shell_quote(format!("--append=rootflags={}", with_removal_option(old)).as_bytes())
),
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 `=`."),
],
[] => 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.")],
_ => 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`.")],
};
}
fn removal_option_lines(facts: &FilesystemFacts, mount: &MountSummary) -> Vec<String> {
let mount_point: &Path = mount.mount_point.as_path();
let mut lines: Vec<String> = vec![
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()),
format!(" Active until the next boot or unmount: sudo mount --options remount,{REMOVAL_OPTION} {}", shell_quote(mount_point.as_os_str().as_bytes())),
];
let on_root_filesystem: bool = facts.root_source.as_deref() == Some(mount.source.as_str());
if facts.ostree_booted && on_root_filesystem {
lines.extend(kernel_argument_lines(facts));
} else if facts.ostree_booted {
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()));
} else {
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."));
}
return lines;
}
fn btrfs_lines(facts: &FilesystemFacts) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
if facts.root_is_subvolume {
lines.push(format!("{} {} is a btrfs subvolume.", SECTION_MARKER, facts.root.display()));
} else {
lines.push(format!("{} {} is on btrfs but is an ordinary directory, not a subvolume.", SECTION_MARKER, facts.root.display()));
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)."));
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."));
lines.push(String::from(" Stop agent sessions and editors that have their working directory inside the checkout first, then:"));
lines.push(String::from(" git cli-git migrate-to-subvolume --dry-run"));
lines.push(String::from(" git cli-git migrate-to-subvolume"));
}
match &facts.mount {
Some(mount) if mount.has_removal_option => {
lines.push(format!(" The mount {} has `{REMOVAL_OPTION}`: a subvolume worktree is deleted in one call.", mount.mount_point.display()));
}
Some(mount) => lines.extend(removal_option_lines(facts, mount)),
None => lines.push(String::from(" The mount table could not be read, so `user_subvol_rm_allowed` was not checked.")),
}
return lines;
}
pub fn render_section(facts: &FilesystemFacts) -> String {
let lines: Vec<String> = match facts.kind {
FilesystemKind::Btrfs => btrfs_lines(facts),
FilesystemKind::Overlayfs => overlay_lines(facts),
FilesystemKind::Other => other_lines(facts),
};
let mut text: String = lines.join("\n");
text.push('\n');
return text;
}
pub fn host_section(common_directory: &Path) -> String {
if common_directory.file_name().is_none_or(|name: &std::ffi::OsStr| { return name != ".git"; }) {
return String::new();
}
let Some(root) = common_directory.parent() else { return String::new() };
return gather_facts(root).map(|facts: FilesystemFacts| { return render_section(&facts); }).unwrap_or_default();
}
#[cfg(test)]
#[path = "doctor_filesystem_tests.rs"]
mod tests;