use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use ignore::WalkBuilder;
pub use ignore::gitignore::Gitignore;
pub use ignore::gitignore::GitignoreBuilder;
pub const SNAPSHOT_IGNORE_FILENAME: &str = ".filesnapignore";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HiddenFiles {
Skip,
Track,
}
pub fn find_workspace_root(start: &Path, markers: &[String]) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
for marker in markers {
if d.join(marker).exists() {
return Some(d.to_path_buf());
}
}
dir = d.parent();
}
None
}
pub fn load_ignore(root: &Path) -> Gitignore {
let mut builder = GitignoreBuilder::new(root);
builder.add(root.join(SNAPSHOT_IGNORE_FILENAME));
builder.build().unwrap_or_else(|_| Gitignore::empty())
}
pub fn is_ignored(ignore: &Gitignore, path: &Path) -> bool {
ignore.matched_path_or_any_parents(path, false).is_ignore()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum DropReason {
OverSizeLimit,
Unreadable,
NotARegularFile,
}
pub type Drop = (PathBuf, DropReason);
#[derive(Debug, Default)]
pub struct Scan {
pub files: BTreeSet<PathBuf>,
pub dropped: Vec<Drop>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScanLimits {
pub max_files: usize,
pub max_file_bytes: u64,
}
impl Default for ScanLimits {
fn default() -> Self {
Self {
max_files: 100,
max_file_bytes: 16 * 1024 * 1024,
}
}
}
pub(crate) const RECENT_SKIP_DIRS: &[&str] = &[
"node_modules", "bower_components", "vendor", "Pods", "Carthage", "deps", ".venv",
"venv", "target", "build", "_build", "dist", "dist-newstyle", "out",
"DerivedData", "__pycache__",
];
pub fn tracked_files(
roots: &[PathBuf],
already_known: impl IntoIterator<Item = PathBuf>,
hidden: HiddenFiles,
limits: ScanLimits,
) -> Scan {
let ignores: Vec<Gitignore> = roots.iter().map(|root| load_ignore(root)).collect();
let mut scan = Scan {
files: already_known.into_iter().collect(),
dropped: Vec::new(),
};
for (root, ignore) in roots.iter().zip(&ignores) {
scan.files.extend(git_tracked_files(root, ignore));
}
for (root, ignore) in roots.iter().zip(&ignores) {
let picked = recent_files(root, ignore, hidden, &scan.files, limits);
scan.files.extend(picked.files);
scan.dropped.extend(picked.dropped);
}
scan
}
pub fn git_tracked_files(root: &Path, ignore: &Gitignore) -> Vec<PathBuf> {
let Ok(repo) = gix::discover(root) else {
return Vec::new();
};
let Ok(index) = repo.index_or_empty() else {
return Vec::new();
};
let Some(workdir) = repo.workdir() else {
return Vec::new();
};
let root_real = root.canonicalize();
let workdir_real = workdir.canonicalize();
let relative = match (&root_real, &workdir_real) {
(Ok(root_real), Ok(workdir_real)) => root_real.strip_prefix(workdir_real).ok(),
_ => root.strip_prefix(workdir).ok(),
};
let Some(relative) = relative else {
return Vec::new();
};
let mut prefix = relative.to_string_lossy().into_owned();
if !prefix.is_empty() && !prefix.ends_with('/') {
prefix.push('/');
}
let entries = if prefix.is_empty() {
index.entries()
} else {
match index.prefixed_entries(prefix.as_bytes().into()) {
Some(entries) => entries,
None => return Vec::new(),
}
};
entries
.iter()
.filter_map(|entry| {
if entry.mode.is_submodule() {
return None;
}
let rel = std::str::from_utf8(entry.path(&index)).ok()?;
let path = root.join(rel.strip_prefix(prefix.as_str())?);
(!is_ignored(ignore, &path)).then_some(path)
})
.collect()
}
pub fn recent_files(
dir: &Path,
ignore: &Gitignore,
hidden: HiddenFiles,
covered: &BTreeSet<PathBuf>,
limits: ScanLimits,
) -> Recent {
let walker = WalkBuilder::new(dir)
.standard_filters(false)
.hidden(hidden == HiddenFiles::Skip)
.follow_links(false)
.filter_entry(|entry| {
let name = entry.file_name();
name != ".git" && !RECENT_SKIP_DIRS.iter().any(|skip| name == *skip)
})
.build();
let mut dropped: Vec<Drop> = Vec::new();
let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
for entry in walker.flatten() {
if !entry.file_type().is_some_and(|t| t.is_file()) {
if entry.file_type().is_some_and(|t| !t.is_dir()) {
let path = entry.into_path();
if !is_ignored(ignore, &path) {
dropped.push((path, DropReason::NotARegularFile));
}
}
continue;
}
let path = entry.into_path();
if covered.contains(&path) || is_ignored(ignore, &path) {
continue;
}
let Ok(meta) = fs::metadata(&path) else {
dropped.push((path, DropReason::Unreadable));
continue;
};
if meta.len() > limits.max_file_bytes {
dropped.push((path, DropReason::OverSizeLimit));
continue;
}
let Ok(modified) = meta.modified() else {
dropped.push((path, DropReason::Unreadable));
continue;
};
candidates.push((modified, path));
}
candidates.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
candidates.truncate(limits.max_files);
Recent {
files: candidates.into_iter().map(|(_, path)| path).collect(),
dropped,
}
}
#[derive(Debug, Default)]
pub struct Recent {
pub files: Vec<PathBuf>,
pub dropped: Vec<Drop>,
}
pub fn scan_report(roots: &[PathBuf], hidden: HiddenFiles, limits: ScanLimits) -> Vec<Drop> {
let scan = tracked_files(roots, [], hidden, limits);
let mut dropped = scan.dropped;
for path in &scan.files {
if fs::File::open(path).is_err() {
dropped.push((path.clone(), DropReason::Unreadable));
}
}
dropped.sort();
dropped.dedup();
dropped
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use pretty_assertions::assert_eq;
fn touch(path: &Path, content: &str) {
fs::write(path, content).unwrap();
}
fn set_mtime_ago(path: &Path, secs: u64) {
let when = std::time::SystemTime::now() - std::time::Duration::from_secs(secs);
let f = fs::File::options().write(true).open(path).unwrap();
f.set_times(fs::FileTimes::new().set_modified(when))
.unwrap();
}
fn git_fixture(paths: &[&str]) -> Option<(PathBuf, tempfile::TempDir)> {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let absent_config = root.join("no-such-gitconfig");
let git = |args: &[&str]| -> bool {
std::process::Command::new("git")
.args(args)
.current_dir(&root)
.env("GIT_CONFIG_GLOBAL", &absent_config)
.env("GIT_CONFIG_SYSTEM", &absent_config)
.output()
.is_ok_and(|out| out.status.success())
};
if !git(&["init", "--quiet"]) {
return None;
}
for rel in paths {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
touch(&path, "content");
}
git(&["add", "-A"]).then_some((root, dir))
}
#[test]
fn walk_up_finds_marker() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("proj");
let deep = root.join("a/b/c");
fs::create_dir_all(&deep).unwrap();
fs::create_dir_all(root.join(".marker")).unwrap();
let markers = vec![".marker".to_string()];
assert_eq!(
find_workspace_root(&deep, &markers),
Some(root),
"nearest ancestor with marker wins"
);
let elsewhere = tempfile::tempdir().unwrap();
assert_eq!(find_workspace_root(elsewhere.path(), &markers), None);
}
#[test]
fn the_recency_walk_respects_the_ignore_file_and_skips_git() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
touch(&root.join("keep.txt"), "k");
touch(&root.join("skip.log"), "s");
fs::create_dir_all(root.join("sub")).unwrap();
touch(&root.join("sub/also.log"), "s");
touch(&root.join("sub/keep2.txt"), "k");
fs::create_dir_all(root.join(".git")).unwrap();
touch(&root.join(".git/HEAD"), "ref");
touch(&root.join(SNAPSHOT_IGNORE_FILENAME), "*.log\n");
let ignore = load_ignore(root);
let names = |hidden: HiddenFiles| -> Vec<String> {
let mut out: Vec<String> = recent_files(
root,
&ignore,
hidden,
&BTreeSet::new(),
ScanLimits::default(),
)
.files
.iter()
.map(|p| p.strip_prefix(root).unwrap().to_string_lossy().into_owned())
.collect();
out.sort();
out
};
assert_eq!(
names(HiddenFiles::Skip),
vec!["keep.txt".to_string(), "sub/keep2.txt".to_string()],
"logs ignored; .git and other dot-entries skipped — including the \
ignore file itself, which follows the same rule as any other \
hidden file and is tracked only if the agent edits it"
);
let with_hidden = names(HiddenFiles::Track);
assert!(with_hidden.contains(&SNAPSHOT_IGNORE_FILENAME.to_string()));
assert!(
with_hidden.iter().all(|name| !name.starts_with(".git/")),
"repository internals are never scanned: {with_hidden:?}"
);
}
#[test]
fn recency_is_bounded_by_count_size_and_churn() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for i in 0..(ScanLimits::default().max_files + 20) {
touch(&root.join(format!("src{i}.rs")), "fn main() {}");
}
std::fs::create_dir_all(root.join("target")).unwrap();
touch(&root.join("target/artifact.o"), "built");
touch(&root.join("huge.bin"), "x");
std::fs::write(
root.join("huge.bin"),
vec![0u8; (ScanLimits::default().max_file_bytes + 1) as usize],
)
.unwrap();
let ignore = load_ignore(root);
let picked = recent_files(
root,
&ignore,
HiddenFiles::Skip,
&BTreeSet::new(),
ScanLimits::default(),
);
assert_eq!(
picked.files.len(),
ScanLimits::default().max_files,
"count is capped"
);
assert!(
!picked.files.iter().any(|p| p.ends_with("artifact.o")),
"churn directories are never descended into"
);
assert!(
!picked.files.iter().any(|p| p.ends_with("huge.bin")),
"oversized files are left out however recent"
);
assert!(
picked
.dropped
.iter()
.any(|(p, why)| p.ends_with("huge.bin") && *why == DropReason::OverSizeLimit),
"{:?}",
picked.dropped
);
assert!(
!picked
.dropped
.iter()
.any(|(p, _)| p.ends_with("artifact.o")),
"an ignored path is not a drop — nothing was left out against the user's wishes"
);
}
#[test]
fn the_report_names_every_file_the_scan_would_leave_out() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
touch(&root.join("ordinary.rs"), "kept");
std::fs::write(
root.join("huge.bin"),
vec![0u8; (ScanLimits::default().max_file_bytes + 1) as usize],
)
.unwrap();
std::fs::write(
root.join("also-huge.bin"),
vec![0u8; (ScanLimits::default().max_file_bytes + 1) as usize],
)
.unwrap();
let report = scan_report(
&[root.to_path_buf()],
HiddenFiles::Skip,
ScanLimits::default(),
);
let names: Vec<String> = report
.iter()
.map(|(p, _)| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["also-huge.bin", "huge.bin"]);
assert!(
report
.iter()
.all(|(_, why)| *why == DropReason::OverSizeLimit)
);
assert!(
!names.iter().any(|n| n == "ordinary.rs"),
"a file that made it in is not a drop"
);
}
#[cfg(unix)]
#[test]
fn the_report_can_name_all_three_reasons() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
touch(&root.join("fine.rs"), "kept");
std::fs::write(
root.join("huge.bin"),
vec![0u8; (ScanLimits::default().max_file_bytes + 1) as usize],
)
.unwrap();
touch(&root.join("secret.pem"), "key material");
fs::set_permissions(root.join("secret.pem"), fs::Permissions::from_mode(0o000)).unwrap();
std::os::unix::fs::symlink(root.join("fine.rs"), root.join("link.rs")).unwrap();
let report = scan_report(
&[root.to_path_buf()],
HiddenFiles::Skip,
ScanLimits::default(),
);
fs::set_permissions(root.join("secret.pem"), fs::Permissions::from_mode(0o644)).unwrap();
let says = |name: &str, why: DropReason| {
report.iter().any(|(p, w)| p.ends_with(name) && *w == why)
};
assert!(says("huge.bin", DropReason::OverSizeLimit), "{report:?}");
assert!(says("secret.pem", DropReason::Unreadable), "{report:?}");
assert!(says("link.rs", DropReason::NotARegularFile), "{report:?}");
assert!(
!report.iter().any(|(p, _)| p.ends_with("fine.rs")),
"a file that made it in is not a drop"
);
}
#[test]
fn recency_spends_its_budget_only_on_what_is_not_covered() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for i in 0..(ScanLimits::default().max_files + 5) {
touch(&root.join(format!("known{i}.rs")), "covered");
}
touch(&root.join("stray.txt"), "the residue");
set_mtime_ago(&root.join("stray.txt"), 3600);
let ignore = load_ignore(root);
let covered: BTreeSet<PathBuf> = (0..(ScanLimits::default().max_files + 5))
.map(|i| root.join(format!("known{i}.rs")))
.collect();
let picked = recent_files(
root,
&ignore,
HiddenFiles::Skip,
&covered,
ScanLimits::default(),
);
assert_eq!(
picked.files,
vec![root.join("stray.txt")],
"the one uncovered file wins, however old"
);
}
#[test]
fn a_directory_without_a_repository_contributes_no_git_partition() {
let dir = tempfile::tempdir().unwrap();
touch(&dir.path().join("a.txt"), "alpha");
let ignore = load_ignore(dir.path());
assert!(git_tracked_files(dir.path(), &ignore).is_empty());
}
#[test]
fn the_git_partition_works_from_a_subdirectory_and_stays_inside_it() {
let Some((repo, _guard)) = git_fixture(&[
"app/main.rs",
"app/deep/util.rs",
"app-extra/other.rs",
"top.rs",
]) else {
return;
};
let sub = repo.join("app");
let ignore = load_ignore(&sub);
let mut found = git_tracked_files(&sub, &ignore);
found.sort();
assert_eq!(
found,
vec![repo.join("app/deep/util.rs"), repo.join("app/main.rs")],
"everything under the session's root, and nothing above or beside \
it — note `app-extra/` shares a prefix with `app` and must not be \
swept in"
);
let ignore = load_ignore(&repo);
assert_eq!(git_tracked_files(&repo, &ignore).len(), 4);
}
#[test]
fn an_indexed_file_missing_from_disk_is_still_reported() {
let Some((repo, _guard)) = git_fixture(&["kept.rs", "vanished.rs"]) else {
return;
};
fs::remove_file(repo.join("vanished.rs")).unwrap();
let ignore = load_ignore(&repo);
let found = git_tracked_files(&repo, &ignore);
assert!(
found.contains(&repo.join("vanished.rs")),
"a path git still tracks must reach the capture even when the \
worktree no longer has it: {found:?}"
);
}
#[test]
fn ignored_paths_are_protected_symmetrically() {
let dir = tempfile::tempdir().unwrap();
touch(&dir.path().join(SNAPSHOT_IGNORE_FILENAME), "secret/**\n");
let ignore = load_ignore(dir.path());
assert!(is_ignored(&ignore, &dir.path().join("secret/key.pem")));
assert!(!is_ignored(&ignore, &dir.path().join("src/main.rs")));
}
}