use std::ffi::OsStr;
use std::fs::{self, File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use flate2::Compression;
use flate2::write::GzEncoder;
use ignore::{DirEntry, WalkBuilder};
use tar::Builder;
use crate::shim::config;
const ALWAYS_IGNORED_PARTS: &[&str] = &[
".falsegreen",
".git",
".hypothesis",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".uv-cache",
".venv",
"__pycache__",
];
const ROOT_IGNORED_PARTS: &[&str] = &["build", "dist", "target"];
pub fn sync(workspace: &Path, key: &str) -> Result<(), String> {
let canonical = workspace
.canonicalize()
.map_err(|error| format!("cannot resolve workspace {}: {error}", workspace.display()))?;
if !canonical.is_dir() {
return Err(format!(
"workspace is not a directory: {}",
canonical.display()
));
}
let archive =
TemporaryArchive::new().map_err(|error| format!("cannot create snapshot: {error}"))?;
create_archive(&canonical, archive.path())
.map_err(|error| format!("cannot snapshot workspace: {error}"))?;
let output = Command::new("curl")
.arg("-sS")
.arg("-f")
.arg("-X")
.arg("PUT")
.arg(config::workspace_url())
.arg("-H")
.arg("Content-Type: application/gzip")
.arg("-H")
.arg(format!("X-API-KEY: {key}"))
.arg("--data-binary")
.arg(format!("@{}", archive.path().display()))
.arg("--max-time")
.arg("120")
.output()
.map_err(|error| format!("failed to upload workspace: {error}"))?;
if !output.status.success() {
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if detail.is_empty() {
"workspace upload failed".to_string()
} else {
format!("workspace upload failed: {detail}")
});
}
Ok(())
}
fn create_archive(workspace: &Path, destination: &Path) -> io::Result<()> {
let file = OpenOptions::new()
.write(true)
.truncate(true)
.open(destination)?;
let encoder = GzEncoder::new(file, Compression::default());
let mut archive = Builder::new(encoder);
archive.follow_symlinks(false);
append_workspace(&mut archive, workspace)?;
let encoder = archive.into_inner()?;
encoder.finish()?;
Ok(())
}
fn append_workspace<W: io::Write>(archive: &mut Builder<W>, workspace: &Path) -> io::Result<()> {
let mut walker = WalkBuilder::new(workspace);
walker
.hidden(false)
.follow_links(false)
.git_ignore(true)
.git_global(false)
.git_exclude(true)
.parents(true)
.sort_by_file_path(|left, right| left.cmp(right))
.filter_entry(|entry| entry.depth() == 0 || !ignored(entry));
for entry in walker.build() {
let entry = entry.map_err(io::Error::other)?;
if entry.depth() == 0 {
continue;
}
let path = entry.path();
let relative = path.strip_prefix(workspace).map_err(io::Error::other)?;
let metadata = fs::symlink_metadata(&path)?;
let file_type = metadata.file_type();
if file_type.is_dir() {
archive.append_dir(relative, &path)?;
} else if file_type.is_file() || file_type.is_symlink() {
archive.append_path_with_name(&path, relative)?;
} else {
return Err(io::Error::other(format!(
"unsupported workspace entry: {}",
relative.display()
)));
}
}
Ok(())
}
fn ignored(entry: &DirEntry) -> bool {
let name = entry.file_name();
name.to_str()
.is_some_and(|name| name.starts_with(".bench-"))
|| ALWAYS_IGNORED_PARTS
.iter()
.any(|ignored| name == OsStr::new(ignored))
|| (entry.depth() == 1
&& ROOT_IGNORED_PARTS
.iter()
.any(|ignored| name == OsStr::new(ignored)))
}
struct TemporaryArchive {
path: PathBuf,
}
impl TemporaryArchive {
fn new() -> io::Result<Self> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"falsegreen-workspace-{}-{nonce}.tar.gz",
std::process::id()
));
File::create_new(&path)?;
Ok(Self { path })
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TemporaryArchive {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::read::GzDecoder;
use std::io::Read;
fn test_directory() -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let directory = std::env::temp_dir().join(format!(
"falsegreen-workspace-test-{}-{nonce}",
std::process::id()
));
fs::create_dir(&directory).unwrap();
directory
}
#[test]
fn archive_contains_source_and_excludes_generated_state() {
let workspace = test_directory();
fs::write(workspace.join("main.rs"), "fn main() {}\n").unwrap();
fs::write(workspace.join("draft.txt"), "untracked source\n").unwrap();
fs::write(workspace.join(".gitignore"), ".bench-*/\n").unwrap();
fs::create_dir(workspace.join("target")).unwrap();
fs::write(workspace.join("target/output"), "ignored").unwrap();
fs::create_dir(workspace.join(".bench-generated")).unwrap();
fs::write(workspace.join(".bench-generated/output"), "ignored").unwrap();
fs::create_dir(workspace.join("src")).unwrap();
fs::write(workspace.join("src/lib.rs"), "pub fn works() {}\n").unwrap();
fs::create_dir_all(workspace.join("src/pip/_internal/operations/build")).unwrap();
fs::write(
workspace.join("src/pip/_internal/operations/build/__init__.py"),
"# tracked source package\n",
)
.unwrap();
let archive_path = workspace.parent().unwrap().join(format!(
"falsegreen-workspace-test-{}.tar.gz",
std::process::id()
));
File::create(&archive_path).unwrap();
create_archive(&workspace, &archive_path).unwrap();
let decoder = GzDecoder::new(File::open(&archive_path).unwrap());
let mut archive = tar::Archive::new(decoder);
let mut paths = Vec::new();
for entry in archive.entries().unwrap() {
let mut entry = entry.unwrap();
paths.push(entry.path().unwrap().to_string_lossy().to_string());
let mut sink = Vec::new();
entry.read_to_end(&mut sink).unwrap();
}
assert!(paths.contains(&"main.rs".to_string()));
assert!(paths.contains(&"draft.txt".to_string()));
assert!(paths.contains(&"src/lib.rs".to_string()));
assert!(paths.contains(&"src/pip/_internal/operations/build/__init__.py".to_string()));
assert!(!paths.iter().any(|path| path.starts_with("target")));
assert!(
!paths
.iter()
.any(|path| path.starts_with(".bench-generated"))
);
fs::remove_file(archive_path).unwrap();
fs::remove_dir_all(workspace).unwrap();
}
#[cfg(unix)]
#[test]
fn archive_preserves_symlinks_instead_of_reading_through_them() {
use std::os::unix::fs::symlink;
let workspace = test_directory();
fs::write(workspace.join("source.txt"), "source\n").unwrap();
symlink("source.txt", workspace.join("current")).unwrap();
let archive_path = workspace.parent().unwrap().join(format!(
"falsegreen-workspace-symlink-test-{}.tar.gz",
std::process::id()
));
File::create(&archive_path).unwrap();
create_archive(&workspace, &archive_path).unwrap();
let decoder = GzDecoder::new(File::open(&archive_path).unwrap());
let mut archive = tar::Archive::new(decoder);
let current = archive
.entries()
.unwrap()
.map(Result::unwrap)
.find(|entry| entry.path().unwrap() == Path::new("current"))
.unwrap();
assert!(current.header().entry_type().is_symlink());
assert_eq!(
current.link_name().unwrap().unwrap(),
Path::new("source.txt")
);
fs::remove_file(archive_path).unwrap();
fs::remove_dir_all(workspace).unwrap();
}
}