use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::stdlib::sandbox::workspace_env;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use ignore::WalkBuilder;
use sha2::{Digest, Sha256};
pub const BUILTIN_IGNORED_DIRS: &[&str] = &[
".git",
".hg",
".svn",
".next",
".venv",
"__pycache__",
"build",
"coverage",
"dist",
"node_modules",
"target",
"venv",
".harn",
".harn-runs",
".harn-tmp",
];
const AGENT_IGNORE_FILENAME: &str = ".agentignore";
pub const PROJECT_IGNORE_FILENAMES: &[&str] = &[".gitignore", ".ignore", AGENT_IGNORE_FILENAME];
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum IgnorePolicy {
None,
Builtin,
#[default]
Project,
}
impl IgnorePolicy {
pub const OPTION_KEY: &'static str = "ignore_policy";
pub const VALUES: &'static [&'static str] = &["none", "builtin", "project"];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Builtin => "builtin",
Self::Project => "project",
}
}
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
match raw {
"none" => Some(Self::None),
"builtin" => Some(Self::Builtin),
"project" => Some(Self::Project),
_ => None,
}
}
pub fn parse_for(builtin: &str, raw: &str) -> Result<Self, String> {
Self::parse(raw).ok_or_else(|| {
format!(
"{builtin}: unknown {key} `{raw}` (expected one of: {values})",
key = Self::OPTION_KEY,
values = Self::VALUES.join(", "),
)
})
}
#[must_use]
pub fn reads_project_files(self) -> bool {
matches!(self, Self::Project)
}
}
pub fn configure(
builder: &mut WalkBuilder,
base: &Path,
policy: IgnorePolicy,
include_hidden: bool,
) -> Result<(), String> {
let anchored_by_vcs = matches!(
project_root_for(&absolutize(base)),
Some((_, ProjectAnchor::Vcs))
);
builder
.hidden(!include_hidden)
.git_global(false)
.git_exclude(false)
.parents(anchored_by_vcs)
.require_git(anchored_by_vcs);
match effective_policy(base, policy) {
IgnorePolicy::None => {
builder.ignore(false).git_ignore(false);
}
IgnorePolicy::Builtin => {
builder.ignore(false).git_ignore(false);
add_builtin_layer(builder)?;
}
IgnorePolicy::Project => {
builder
.ignore(true)
.git_ignore(true)
.add_custom_ignore_filename(AGENT_IGNORE_FILENAME);
add_builtin_layer(builder)?;
}
}
Ok(())
}
#[must_use]
pub fn matcher(base: &Path, policy: IgnorePolicy) -> Gitignore {
let effective = effective_policy(base, policy);
if effective == IgnorePolicy::None {
return Gitignore::empty();
}
let mut builder = GitignoreBuilder::new(base);
for name in BUILTIN_IGNORED_DIRS {
let _ = builder.add_line(None, &format!("{name}/"));
}
if effective.reads_project_files() {
for name in PROJECT_IGNORE_FILENAMES {
let _ = builder.add(base.join(name));
}
}
builder.build().unwrap_or_else(|_| Gitignore::empty())
}
#[must_use]
pub fn effective_policy(base: &Path, requested: IgnorePolicy) -> IgnorePolicy {
if requested == IgnorePolicy::None {
return IgnorePolicy::None;
}
let resolved = absolutize(base);
if is_scratch_path(&resolved) || project_root_for(&resolved).is_none() {
return IgnorePolicy::Builtin;
}
requested
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ProjectAnchor {
Vcs,
SandboxWorkspace,
}
#[must_use]
pub fn project_root_for(path: &Path) -> Option<(PathBuf, ProjectAnchor)> {
for ancestor in path.ancestors() {
if ancestor.join(".git").exists() || ancestor.join(".jj").exists() {
return Some((ancestor.to_path_buf(), ProjectAnchor::Vcs));
}
}
let roots = sandbox_workspace_roots();
path.ancestors()
.find(|ancestor| roots.iter().any(|root| root == *ancestor))
.map(|ancestor| (ancestor.to_path_buf(), ProjectAnchor::SandboxWorkspace))
}
#[must_use]
pub fn is_scratch_path(path: &Path) -> bool {
path.components().any(|component| {
matches!(
component.as_os_str().to_str(),
Some(workspace_env::WORKSPACE_TMPDIR_NAME)
| Some(workspace_env::WORKSPACE_TOOLCHAIN_CACHE_NAME)
)
})
}
fn sandbox_workspace_roots() -> Vec<PathBuf> {
let Some(policy) = crate::orchestration::current_execution_policy() else {
return Vec::new();
};
policy
.workspace_roots
.iter()
.map(|root| absolutize(Path::new(root)))
.collect()
}
fn absolutize(path: &Path) -> PathBuf {
path.canonicalize()
.or_else(|_| std::path::absolute(path))
.unwrap_or_else(|_| path.to_path_buf())
}
fn add_builtin_layer(builder: &mut WalkBuilder) -> Result<(), String> {
let path = builtin_ignore_file()?;
if let Some(error) = builder.add_ignore(path) {
return Err(format!(
"ignore policy: built-in ignore layer at {} is invalid: {error}",
path.display()
));
}
Ok(())
}
fn builtin_ignore_text() -> String {
let mut text =
String::from("# Generated by Harn. Lowest-precedence ignore layer; safe to delete.\n");
for name in BUILTIN_IGNORED_DIRS {
text.push_str(name);
text.push_str("/\n");
}
text
}
fn builtin_ignore_file() -> Result<&'static Path, String> {
static PATH: OnceLock<Result<PathBuf, String>> = OnceLock::new();
PATH.get_or_init(materialize_builtin_ignore_file)
.as_deref()
.map_err(Clone::clone)
}
fn materialize_builtin_ignore_file() -> Result<PathBuf, String> {
let text = builtin_ignore_text();
let digest = hex16(Sha256::digest(text.as_bytes()).as_slice());
let dir = std::env::temp_dir();
let target = dir.join(format!("harn-builtin-ignore-{digest}"));
if std::fs::read_to_string(&target).is_ok_and(|existing| existing == text) {
return Ok(target);
}
let scratch = dir.join(format!(
"harn-builtin-ignore-{digest}.{}.{}.partial",
std::process::id(),
uuid::Uuid::now_v7().simple(),
));
std::fs::write(&scratch, &text).map_err(|error| {
format!(
"ignore policy: cannot materialize the built-in ignore layer at {}: {error}",
scratch.display()
)
})?;
match std::fs::rename(&scratch, &target) {
Ok(()) => Ok(target),
Err(_) => Ok(scratch),
}
}
fn hex16(bytes: &[u8]) -> String {
bytes
.iter()
.take(8)
.map(|byte| format!("{byte:02x}"))
.collect()
}
#[cfg(test)]
mod tests;