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 (XFS made with reflink support clones the tracked files of a clean worktree instead).",
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, and `git worktree add` clones the tracked files of a clean worktree instead of letting Git check them out; 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's own checkout, which the wrapper falls back to when the worktree it is run from is not clean, 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, `git worktree remove` of a worktree that is a subvolume works but unlinks its files one by one; with it, the wrapper deletes a clean worktree's subvolume with one call.", 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()));
lines.push(String::from(" `git worktree add` makes the new worktree from a snapshot of the worktree it is run from when that worktree is clean, and makes every new worktree a subvolume, so the next one can be a snapshot too. A worktree that is not clean, has a sparse checkout or worktree-specific configuration, or has a nested worktree that is an ordinary directory is cloned file by file instead, or checked out by Git."));
} 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: `git worktree add` then makes the new worktree from a btrfs snapshot of the worktree it is run from, 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 shares every data block with its source (doc/planning/cli-git-btrfs-worktree.md, section Measurements)."));
lines.push(String::from(" Until then `git worktree add` clones the tracked files of a clean worktree, which shares disk but takes about as long as Git's checkout, and makes every new worktree a subvolume that later ones can be snapshotted from; worktrees that exist already as ordinary directories stay ordinary. 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}`: `git worktree remove` deletes a clean subvolume worktree 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;