use std::ffi::{OsStr, OsString};
use std::fs;
use std::io::Read;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::os::unix::fs::{symlink, PermissionsExt};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub struct MountedTimefs {
mountpoint: PathBuf,
child: Option<Child>,
}
pub struct MountRunResult {
pub stderr: String,
}
impl MountedTimefs {
pub fn mount(repo: &Path, cli_args: &[&str], mount_args: &[&str]) -> Self {
let mountpoint = create_temp_dir("timefs-it-mount");
let child = Command::new(timefs_binary())
.args(cli_args)
.arg("mount")
.arg(repo)
.arg(&mountpoint)
.args(mount_args)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("timefs mount should spawn");
let mut mount = Self {
mountpoint,
child: Some(child),
};
mount.wait_until_ready(b"now");
mount
}
pub fn path(&self) -> &Path {
&self.mountpoint
}
pub fn finish(mut self) -> MountRunResult {
self.unmount();
MountRunResult {
stderr: self.wait_for_exit("mount process should exit after explicit unmount"),
}
}
fn wait_until_ready(&mut self, expected_name: &[u8]) {
for _ in 0..120 {
if let Ok(entries) = fs::read_dir(&self.mountpoint) {
let names: Vec<Vec<u8>> = entries
.filter_map(Result::ok)
.map(|entry| entry.file_name().into_vec())
.collect();
if names.iter().any(|name| name == expected_name) {
return;
}
}
if let Some(child) = self.child.as_mut() {
if let Some(status) = child
.try_wait()
.expect("mount child status should be readable")
{
panic!(
"mount process exited early with status {}: {}",
status,
child_stderr(child)
);
}
}
thread::sleep(Duration::from_millis(50));
}
panic!(
"mountpoint did not become ready at {}",
self.mountpoint.display()
);
}
fn unmount(&self) {
run_timefs(["unmount", self.mountpoint.to_string_lossy().as_ref()]);
}
fn wait_for_exit(&mut self, message: &str) -> String {
let Some(mut child) = self.child.take() else {
return String::new();
};
let status = child.wait().expect("mount child status should be readable");
let stderr = child_stderr(&mut child);
assert!(status.success(), "{message}: {stderr}");
wait_for_mount_cleared(&self.mountpoint);
stderr
}
}
impl Drop for MountedTimefs {
fn drop(&mut self) {
if self.child.is_none() {
return;
}
self.unmount();
let _ = self.wait_for_exit("mount process should exit during drop cleanup");
}
}
pub fn with_mounted_timefs<T, F>(
repo: &Path,
cli_args: &[&str],
mount_args: &[&str],
body: F,
) -> (T, MountRunResult)
where
F: FnOnce(&MountedTimefs) -> T,
{
let mount = MountedTimefs::mount(repo, cli_args, mount_args);
let value = body(&mount);
let result = mount.finish();
(value, result)
}
pub struct RepositoryFixture {
root: PathBuf,
}
impl RepositoryFixture {
pub fn new(prefix: &str) -> Self {
let root = unique_temp_path(prefix);
fs::create_dir_all(&root).expect("fixture directory creation should succeed");
let fixture = Self { root };
fixture.git(["init", "-b", "main"]);
fixture.git(["config", "user.name", "Timefs Tests"]);
fixture.git(["config", "user.email", "timefs-tests@example.com"]);
fixture
}
pub fn path(&self) -> &Path {
&self.root
}
pub fn git<I, S>(&self, args: I) -> Output
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
run_git_in(&self.root, args)
}
#[allow(dead_code)]
pub fn git_with_options<A, B, S1, S2>(&self, options: A, args: B) -> Output
where
A: IntoIterator<Item = S1>,
B: IntoIterator<Item = S2>,
S1: AsRef<OsStr>,
S2: AsRef<OsStr>,
{
let output = Command::new("git")
.args(options)
.args(args)
.current_dir(&self.root)
.output()
.expect("git should be executable in tests");
if output.status.success() {
output
} else {
panic!(
"git command failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
}
pub fn git_ls_tree_recursive(&self, rev: &str) -> Vec<GitEntry> {
parse_ls_tree_entries(&self.git(["ls-tree", "-r", "-z", rev]).stdout)
}
pub fn git_show_path(&self, rev: &str, path: &[u8]) -> Vec<u8> {
let spec = format!("{rev}:{}", String::from_utf8_lossy(path));
self.git(["show", spec.as_str()]).stdout
}
#[allow(dead_code)]
pub fn git_rev_parse(&self, spec: &str) -> String {
String::from_utf8(self.git(["rev-parse", spec]).stdout)
.expect("rev-parse output should be valid UTF-8")
.trim()
.to_owned()
}
#[allow(dead_code)]
pub fn git_rev_parse_short(&self, spec: &str) -> String {
String::from_utf8(self.git(["rev-parse", "--short", spec]).stdout)
.expect("short rev-parse output should be valid UTF-8")
.trim()
.to_owned()
}
#[allow(dead_code)]
pub fn remove_worktree_entries(&self) {
let entries = fs::read_dir(&self.root).expect("fixture directory should be readable");
for entry in entries.filter_map(Result::ok) {
if entry.file_name() == OsStr::new(".git") {
continue;
}
let path = entry.path();
let file_type = entry
.file_type()
.expect("fixture file type should be readable");
if file_type.is_dir() {
fs::remove_dir_all(&path).expect("fixture subtree removal should succeed");
} else {
fs::remove_file(&path).expect("fixture file removal should succeed");
}
}
}
}
impl Drop for RepositoryFixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
#[derive(Debug)]
pub struct GitEntry {
pub mode: u32,
pub kind: String,
pub path: Vec<u8>,
}
pub fn assert_revision_tree_matches_git(fixture: &RepositoryFixture, mount_root: &Path, rev: &str) {
let git_entries = fixture.git_ls_tree_recursive(rev);
let mount_paths = collect_mount_leaf_paths(mount_root);
assert_eq!(
mount_paths,
git_entries
.iter()
.map(|entry| entry.path.clone())
.collect::<Vec<_>>()
);
for entry in &git_entries {
let mounted_path = mount_root.join(OsString::from_vec(entry.path.clone()));
match entry.kind.as_str() {
"blob" if entry.mode == 0o120000 => {
let target = fs::read_link(&mounted_path).expect("symlink should resolve");
assert_eq!(
target.as_os_str().as_bytes(),
fixture.git_show_path(rev, &entry.path)
);
}
"blob" => {
assert_eq!(
fs::read(&mounted_path).expect("file should be readable"),
fixture.git_show_path(rev, &entry.path)
);
}
other => panic!("unexpected git entry kind in fixture: {other}"),
}
}
}
pub fn collect_mount_leaf_paths(root: &Path) -> Vec<Vec<u8>> {
let mut paths = Vec::new();
collect_mount_leaf_paths_recursive(root, root, &mut paths);
paths.sort();
paths
}
pub fn create_temp_dir(prefix: &str) -> PathBuf {
let path = unique_temp_path(prefix);
fs::create_dir_all(&path).expect("temporary directory creation should succeed");
path
}
pub fn run_git_in<I, S>(root: &Path, args: I) -> Output
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.expect("git should be executable in tests");
if output.status.success() {
output
} else {
panic!(
"git command failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
}
pub fn make_executable(path: &Path) {
let mut permissions = fs::metadata(path)
.expect("script metadata should be readable")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).expect("script permissions should be writable");
}
pub fn write_symlink(target: &str, path: &Path) {
symlink(target, path).expect("symlink creation should succeed");
}
pub fn parse_ls_tree_entries(output: &[u8]) -> Vec<GitEntry> {
output
.split(|byte| *byte == 0)
.filter(|record| !record.is_empty())
.map(parse_ls_tree_entry)
.collect()
}
fn parse_ls_tree_entry(record: &[u8]) -> GitEntry {
let tab = record
.iter()
.position(|byte| *byte == b'\t')
.expect("ls-tree records should contain a tab");
let (header, path) = record.split_at(tab);
let header = std::str::from_utf8(header).expect("ls-tree header should be UTF-8");
let mut parts = header.split_whitespace();
let mode = u32::from_str_radix(parts.next().expect("mode should exist"), 8)
.expect("mode should be valid octal");
let kind = parts.next().expect("kind should exist").to_owned();
let _oid = parts.next().expect("oid should exist");
GitEntry {
mode,
kind,
path: path.get(1..).unwrap_or_default().to_vec(),
}
}
fn run_timefs<I, S>(args: I)
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = Command::new(timefs_binary())
.args(args)
.output()
.expect("timefs command should execute");
if !output.status.success() {
panic!(
"timefs command failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
}
fn wait_for_mount_cleared(mountpoint: &Path) {
for _ in 0..120 {
if let Ok(entries) = fs::read_dir(mountpoint) {
if entries.count() == 0 {
return;
}
}
thread::sleep(Duration::from_millis(50));
}
panic!(
"mountpoint did not clear after unmount: {}",
mountpoint.display()
);
}
fn child_stderr(child: &mut Child) -> String {
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
let _ = pipe.read_to_string(&mut stderr);
}
stderr
}
fn collect_mount_leaf_paths_recursive(root: &Path, dir: &Path, out: &mut Vec<Vec<u8>>) {
let mut entries: Vec<_> = fs::read_dir(dir)
.expect("mounted directory should be readable")
.filter_map(Result::ok)
.collect();
entries.sort_by(|left, right| left.file_name().cmp(&right.file_name()));
for entry in entries {
let path = entry.path();
let file_type = entry.file_type().expect("entry type should be readable");
if file_type.is_dir() {
collect_mount_leaf_paths_recursive(root, &path, out);
} else {
let relative = path
.strip_prefix(root)
.expect("path should remain under root")
.as_os_str()
.as_bytes()
.to_vec();
out.push(relative);
}
}
}
fn unique_temp_path(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after the Unix epoch")
.as_nanos();
let mut path = std::env::temp_dir();
path.push(OsString::from_vec(format!("{prefix}-{nanos}").into_bytes()));
path
}
fn timefs_binary() -> OsString {
OsString::from(env!("CARGO_BIN_EXE_timefs"))
}