use std::path::{Path, PathBuf};
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);
let scratch_root = managed_scratch_root(&resolved);
match project_root_for(&resolved) {
Some((root, ProjectAnchor::Vcs))
if scratch_root.is_none_or(|scratch| root.starts_with(scratch)) =>
{
requested
}
Some((_, ProjectAnchor::SandboxWorkspace)) if scratch_root.is_none() => requested,
Some((_, ProjectAnchor::Vcs | ProjectAnchor::SandboxWorkspace)) | None => {
IgnorePolicy::Builtin
}
}
}
#[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 {
managed_scratch_root(path).is_some()
}
fn managed_scratch_root(path: &Path) -> Option<&Path> {
path.ancestors().find(|ancestor| {
matches!(
ancestor.file_name().and_then(|name| name.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<PathBuf, String> {
static PATH: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
ensure_builtin_ignore_file(&PATH)
}
fn ensure_builtin_ignore_file(
cache: &std::sync::Mutex<Option<PathBuf>>,
) -> Result<PathBuf, String> {
let text = builtin_ignore_text();
let mut cached = cache
.lock()
.map_err(|_| "ignore policy: built-in ignore path cache is poisoned".to_string())?;
if let Some(path) = cached.as_ref() {
if std::fs::read_to_string(path).is_ok_and(|existing| existing == text) {
return Ok(path.clone());
}
}
let path = materialize_builtin_ignore_file(&text)?;
*cached = Some(path.clone());
Ok(path)
}
fn materialize_builtin_ignore_file(text: &str) -> Result<PathBuf, String> {
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;