Fix skill discovery and listing with scoped skill creation
This commit is contained in:
@@ -113,6 +113,7 @@ impl From<api::message::tool_call::ApplyFileDiffs> for AIAgentActionType {
|
||||
.map(|new_file| FileEdit::Create {
|
||||
file: new_file.file_path.none_if_default(),
|
||||
content: new_file.content.none_if_default(),
|
||||
creation_scope: None,
|
||||
});
|
||||
|
||||
AIAgentActionType::RequestFileEdits {
|
||||
|
||||
@@ -937,6 +937,8 @@ pub enum FileEdit {
|
||||
Create {
|
||||
file: Option<String>,
|
||||
content: Option<String>,
|
||||
/// When present, create exclusively within this canonical local scope at save time.
|
||||
creation_scope: Option<PathBuf>,
|
||||
},
|
||||
/// Delete an existing file.
|
||||
Delete { file: Option<String> },
|
||||
|
||||
@@ -21,6 +21,9 @@ notify-debouncer-full.workspace = true
|
||||
repo_metadata.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc.workspace = true
|
||||
|
||||
[features]
|
||||
test-util = []
|
||||
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Creates a new file without following changed project-scope paths or replacing existing data.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Component, Path};
|
||||
|
||||
pub(super) fn write_new_file(path: &Path, scope_root: &Path, content: &[u8]) -> io::Result<()> {
|
||||
let components = scoped_components(path, scope_root)?;
|
||||
let (name, parents) = components.split_last().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "A new file name is required.")
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
return unix::write(scope_root, parents, name, content);
|
||||
#[cfg(not(unix))]
|
||||
return portable::write(scope_root, parents, name, content);
|
||||
}
|
||||
|
||||
pub(super) fn remove_created_file(
|
||||
path: &Path,
|
||||
scope_root: &Path,
|
||||
expected_content: &[u8],
|
||||
) -> io::Result<()> {
|
||||
let components = scoped_components(path, scope_root)?;
|
||||
let (name, parents) = components.split_last().ok_or_else(changed_file)?;
|
||||
#[cfg(unix)]
|
||||
return unix::remove(scope_root, parents, name, expected_content);
|
||||
#[cfg(not(unix))]
|
||||
return portable::remove(scope_root, parents, name, expected_content);
|
||||
}
|
||||
|
||||
fn scoped_components<'a>(path: &'a Path, scope_root: &Path) -> io::Result<Vec<&'a OsStr>> {
|
||||
// The caller captured a canonical scope before permission review. Refuse a root that has
|
||||
// subsequently become a symlink to another location, rather than approving that new scope.
|
||||
if fs::canonicalize(scope_root)? != scope_root {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"The file creation scope changed after this action was prepared.",
|
||||
));
|
||||
}
|
||||
let relative = path.strip_prefix(scope_root).map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"The new file must remain inside the selected scope.",
|
||||
)
|
||||
})?;
|
||||
relative
|
||||
.components()
|
||||
.map(|component| match component {
|
||||
Component::Normal(name) => Ok(name),
|
||||
Component::Prefix(_)
|
||||
| Component::RootDir
|
||||
| Component::CurDir
|
||||
| Component::ParentDir => Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"The new file path must not traverse outside its selected scope.",
|
||||
)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix {
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn c_name(name: &OsStr) -> io::Result<CString> {
|
||||
CString::new(name.as_bytes()).map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"File names must not contain NUL.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn open_directory(parent: RawFd, name: &CStr) -> io::Result<OwnedFd> {
|
||||
// Each lookup is relative to a directory we already opened. O_NOFOLLOW prevents a
|
||||
// symlink swapped in during permission review or concurrent directory creation from
|
||||
// redirecting this traversal outside the selected directory tree.
|
||||
let fd = unsafe {
|
||||
libc::openat(
|
||||
parent,
|
||||
name.as_ptr(),
|
||||
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
// SAFETY: openat returned a new descriptor owned by this call.
|
||||
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
|
||||
}
|
||||
}
|
||||
|
||||
fn open_parent(scope_root: &Path, parents: &[&OsStr], create: bool) -> io::Result<OwnedFd> {
|
||||
// Walk the canonical scope itself without following symlinks, including its ancestors.
|
||||
// A root replaced between canonicalization and open must not select a different tree.
|
||||
let mut directory: OwnedFd = fs::File::open("/")?.into();
|
||||
for component in scope_root.components() {
|
||||
match component {
|
||||
Component::RootDir => {}
|
||||
Component::Normal(name) => {
|
||||
directory = open_directory(directory.as_raw_fd(), &c_name(name)?)?;
|
||||
}
|
||||
Component::Prefix(_) | Component::CurDir | Component::ParentDir => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"The selected scope must be a canonical absolute directory.",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in parents {
|
||||
let name = c_name(name)?;
|
||||
directory = match open_directory(directory.as_raw_fd(), &name) {
|
||||
Ok(child) => child,
|
||||
Err(error) if create && error.kind() == io::ErrorKind::NotFound => {
|
||||
let created =
|
||||
unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o777) };
|
||||
if created < 0 {
|
||||
let error = io::Error::last_os_error();
|
||||
if error.kind() != io::ErrorKind::AlreadyExists {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
open_directory(directory.as_raw_fd(), &name)?
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
}
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
pub(super) fn write(
|
||||
scope_root: &Path,
|
||||
parents: &[&OsStr],
|
||||
name: &OsStr,
|
||||
content: &[u8],
|
||||
) -> io::Result<()> {
|
||||
let directory = open_parent(scope_root, parents, true)?;
|
||||
let name = c_name(name)?;
|
||||
let fd = unsafe {
|
||||
libc::openat(
|
||||
directory.as_raw_fd(),
|
||||
name.as_ptr(),
|
||||
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
0o666,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: openat returned a new descriptor owned by this call. O_EXCL ensures an existing
|
||||
// file, including a dangling leaf symlink, is never opened for truncation or writing.
|
||||
let mut file = unsafe { fs::File::from_raw_fd(fd) };
|
||||
file.write_all(content)
|
||||
}
|
||||
|
||||
fn open_leaf(parent: RawFd, name: &CStr) -> io::Result<fs::File> {
|
||||
let fd = unsafe {
|
||||
libc::openat(
|
||||
parent,
|
||||
name.as_ptr(),
|
||||
libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
// SAFETY: openat returned a new descriptor owned by this call.
|
||||
Ok(unsafe { fs::File::from_raw_fd(fd) })
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove(
|
||||
scope_root: &Path,
|
||||
parents: &[&OsStr],
|
||||
name: &OsStr,
|
||||
expected_content: &[u8],
|
||||
) -> io::Result<()> {
|
||||
let directory = open_parent(scope_root, parents, false)?;
|
||||
let name = c_name(name)?;
|
||||
let mut file = open_leaf(directory.as_raw_fd(), &name)?;
|
||||
let metadata = check_expected_content(&mut file, expected_content)?;
|
||||
// Reopen the current leaf without following links and compare its identity/metadata to
|
||||
// the file just read. Revert must preserve a replacement or a subsequently edited file.
|
||||
let current = open_leaf(directory.as_raw_fd(), &name)?;
|
||||
if !same_file(&metadata, ¤t.metadata()?) {
|
||||
return Err(changed_file());
|
||||
}
|
||||
if unsafe { libc::unlinkat(directory.as_raw_fd(), name.as_ptr(), 0) } < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
mod portable {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn check_directory(path: &Path, scope_root: &Path) -> io::Result<()> {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if !metadata.is_dir()
|
||||
|| redirects(&metadata)
|
||||
|| fs::canonicalize(path)? != path
|
||||
|| !path.starts_with(scope_root)
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"The file creation directory is no longer inside its selected scope.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parent_path(scope_root: &Path, parents: &[&OsStr], create: bool) -> io::Result<PathBuf> {
|
||||
check_directory(scope_root, scope_root)?;
|
||||
let mut parent = scope_root.to_path_buf();
|
||||
for name in parents {
|
||||
parent.push(name);
|
||||
if create {
|
||||
match fs::create_dir(&parent) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
check_directory(&parent, scope_root)?;
|
||||
}
|
||||
// Windows does not expose directory-relative open through std. Recheck the parent at the
|
||||
// write boundary; create_new still guarantees that an existing leaf is never overwritten.
|
||||
check_directory(&parent, scope_root)?;
|
||||
Ok(parent)
|
||||
}
|
||||
|
||||
pub(super) fn write(
|
||||
scope_root: &Path,
|
||||
parents: &[&OsStr],
|
||||
name: &OsStr,
|
||||
content: &[u8],
|
||||
) -> io::Result<()> {
|
||||
let parent = parent_path(scope_root, parents, true)?;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(parent.join(name))?;
|
||||
file.write_all(content)
|
||||
}
|
||||
|
||||
pub(super) fn remove(
|
||||
scope_root: &Path,
|
||||
parents: &[&OsStr],
|
||||
name: &OsStr,
|
||||
expected_content: &[u8],
|
||||
) -> io::Result<()> {
|
||||
let parent = parent_path(scope_root, parents, false)?;
|
||||
let path = parent.join(name);
|
||||
if redirects(&fs::symlink_metadata(&path)?) {
|
||||
return Err(changed_file());
|
||||
}
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||||
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
|
||||
}
|
||||
let mut file = options.open(&path)?;
|
||||
let metadata = check_expected_content(&mut file, expected_content)?;
|
||||
check_directory(&parent, scope_root)?;
|
||||
if !same_file(&metadata, &fs::symlink_metadata(&path)?) {
|
||||
return Err(changed_file());
|
||||
}
|
||||
// Close before removing on Windows, where an open file may not share delete access.
|
||||
drop(file);
|
||||
fs::remove_file(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn changed_file() -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"The created file changed; preserve it and review its current contents before deletion.",
|
||||
)
|
||||
}
|
||||
|
||||
fn check_expected_content(file: &mut fs::File, expected: &[u8]) -> io::Result<fs::Metadata> {
|
||||
let before = file.metadata()?;
|
||||
if !before.is_file() || redirects(&before) || before.len() != expected.len() as u64 {
|
||||
return Err(changed_file());
|
||||
}
|
||||
let mut contents = Vec::with_capacity(expected.len());
|
||||
Read::by_ref(file)
|
||||
.take((expected.len() as u64).saturating_add(1))
|
||||
.read_to_end(&mut contents)?;
|
||||
let after = file.metadata()?;
|
||||
if contents != expected || !same_file(&before, &after) {
|
||||
return Err(changed_file());
|
||||
}
|
||||
Ok(after)
|
||||
}
|
||||
|
||||
fn redirects(metadata: &fs::Metadata) -> bool {
|
||||
let redirects = metadata.file_type().is_symlink();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||
return redirects || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
redirects
|
||||
}
|
||||
|
||||
fn same_file(before: &fs::Metadata, after: &fs::Metadata) -> bool {
|
||||
if !after.is_file() || redirects(after) || before.len() != after.len() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
before.dev() == after.dev()
|
||||
&& before.ino() == after.ino()
|
||||
&& before.mode() == after.mode()
|
||||
&& before.mtime() == after.mtime()
|
||||
&& before.mtime_nsec() == after.mtime_nsec()
|
||||
&& before.ctime() == after.ctime()
|
||||
&& before.ctime_nsec() == after.ctime_nsec()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
before.modified().ok() == after.modified().ok()
|
||||
&& before.created().ok() == after.created().ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "guarded_create_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,192 @@
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::{fs, io};
|
||||
|
||||
use super::{remove_created_file, write_new_file};
|
||||
|
||||
#[test]
|
||||
fn guarded_undo_removes_only_unchanged_created_content() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let scope = fs::canonicalize(temporary.path()).unwrap();
|
||||
let path = scope.join(".agents/skills/check/SKILL.md");
|
||||
write_new_file(&path, &scope, b"created skill").unwrap();
|
||||
remove_created_file(&path, &scope, b"created skill").unwrap();
|
||||
assert!(!path.exists());
|
||||
|
||||
write_new_file(&path, &scope, b"created skill").unwrap();
|
||||
fs::write(&path, b"external edit").unwrap();
|
||||
let error = remove_created_file(&path, &scope, b"created skill").unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert_eq!(fs::read(path).unwrap(), b"external edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guarded_undo_does_not_create_missing_parent_directories() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let scope = fs::canonicalize(temporary.path()).unwrap();
|
||||
assert!(remove_created_file(
|
||||
&scope.join(".agents/skills/check/SKILL.md"),
|
||||
&scope,
|
||||
b"created skill"
|
||||
)
|
||||
.is_err());
|
||||
assert!(!scope.join(".agents").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn guarded_undo_preserves_redirected_parent_and_leaf_targets() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let base = fs::canonicalize(temporary.path()).unwrap();
|
||||
let scope = base.join("project");
|
||||
let path = scope.join(".agents/skills/check/SKILL.md");
|
||||
fs::create_dir(&scope).unwrap();
|
||||
write_new_file(&path, &scope, b"created skill").unwrap();
|
||||
fs::rename(scope.join(".agents"), base.join("moved-agents")).unwrap();
|
||||
symlink(base.join("moved-agents"), scope.join(".agents")).unwrap();
|
||||
assert!(remove_created_file(&path, &scope, b"created skill").is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), b"created skill");
|
||||
|
||||
fs::remove_file(scope.join(".agents")).unwrap();
|
||||
fs::rename(base.join("moved-agents"), scope.join(".agents")).unwrap();
|
||||
fs::rename(&path, base.join("moved-skill.md")).unwrap();
|
||||
symlink(base.join("moved-skill.md"), &path).unwrap();
|
||||
assert!(remove_created_file(&path, &scope, b"created skill").is_err());
|
||||
assert_eq!(
|
||||
fs::read(base.join("moved-skill.md")).unwrap(),
|
||||
b"created skill"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_missing_skill_directories_without_replacing_an_existing_skill() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let scope = fs::canonicalize(temporary.path()).unwrap();
|
||||
let path = scope.join(".agents/skills/check/SKILL.md");
|
||||
|
||||
write_new_file(&path, &scope, b"original").unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"original");
|
||||
|
||||
let error = write_new_file(&path, &scope, b"replacement").unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(fs::read(&path).unwrap(), b"original");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_outside_and_parent_traversal_before_creating_directories() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let base = fs::canonicalize(temporary.path()).unwrap();
|
||||
let scope = base.join("project");
|
||||
fs::create_dir(&scope).unwrap();
|
||||
|
||||
for path in [
|
||||
base.join("outside/.agents/skills/check/SKILL.md"),
|
||||
scope.join("../outside/.agents/skills/check/SKILL.md"),
|
||||
] {
|
||||
let error = write_new_file(&path, &scope, b"skill").unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
}
|
||||
assert!(!base.join("outside").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_creators_cannot_overwrite_each_other() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let scope = Arc::new(fs::canonicalize(temporary.path()).unwrap());
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let creators = [b"first".as_slice(), b"second".as_slice()].map(|content| {
|
||||
let scope = Arc::clone(&scope);
|
||||
let barrier = Arc::clone(&barrier);
|
||||
std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
(
|
||||
content,
|
||||
write_new_file(
|
||||
&scope.join(".agents/skills/check/SKILL.md"),
|
||||
&scope,
|
||||
content,
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
barrier.wait();
|
||||
let outcomes = creators.map(|creator| creator.join().unwrap());
|
||||
assert_eq!(
|
||||
outcomes.iter().filter(|(_, result)| result.is_ok()).count(),
|
||||
1
|
||||
);
|
||||
let expected = outcomes
|
||||
.iter()
|
||||
.find(|(_, result)| result.is_ok())
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(
|
||||
fs::read(scope.join(".agents/skills/check/SKILL.md")).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
outcomes
|
||||
.into_iter()
|
||||
.find_map(|(_, result)| result.err())
|
||||
.unwrap()
|
||||
.kind(),
|
||||
io::ErrorKind::AlreadyExists
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_a_scope_replaced_by_a_symlink_after_preparation() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let base = fs::canonicalize(temporary.path()).unwrap();
|
||||
let scope = base.join("project");
|
||||
let outside = base.join("outside");
|
||||
fs::create_dir(&scope).unwrap();
|
||||
fs::create_dir(&outside).unwrap();
|
||||
fs::rename(&scope, base.join("original-project")).unwrap();
|
||||
symlink(&outside, &scope).unwrap();
|
||||
|
||||
let error = write_new_file(
|
||||
&scope.join(".agents/skills/check/SKILL.md"),
|
||||
&scope,
|
||||
b"skill",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert!(!outside.join(".agents").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_changed_parent_directories_and_leaf_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let base = fs::canonicalize(temporary.path()).unwrap();
|
||||
let scope = base.join("project");
|
||||
let outside = base.join("outside");
|
||||
fs::create_dir(&scope).unwrap();
|
||||
fs::create_dir(&outside).unwrap();
|
||||
symlink(&outside, scope.join(".agents")).unwrap();
|
||||
|
||||
assert!(write_new_file(
|
||||
&scope.join(".agents/skills/check/SKILL.md"),
|
||||
&scope,
|
||||
b"skill"
|
||||
)
|
||||
.is_err());
|
||||
assert!(!outside.join("skills").exists());
|
||||
|
||||
fs::remove_file(scope.join(".agents")).unwrap();
|
||||
let parent = scope.join(".agents/skills/check");
|
||||
fs::create_dir_all(&parent).unwrap();
|
||||
let original = outside.join("original.md");
|
||||
fs::write(&original, b"original").unwrap();
|
||||
symlink(&original, parent.join("SKILL.md")).unwrap();
|
||||
let error = write_new_file(&parent.join("SKILL.md"), &scope, b"replacement").unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(fs::read(original).unwrap(), b"original");
|
||||
}
|
||||
@@ -27,6 +27,7 @@ use repo_metadata::repository::{RepositorySubscriber, SubscriberId};
|
||||
use repo_metadata::{CanonicalizedPath, Repository, RepositoryUpdate};
|
||||
use watcher::{BulkFilesystemWatcher, BulkFilesystemWatcherEvent};
|
||||
|
||||
mod guarded_create;
|
||||
pub mod text_file_reader;
|
||||
pub use text_file_reader::{TextFileReadResult, TextFileSegment};
|
||||
|
||||
@@ -756,6 +757,57 @@ impl FileModel {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a new local file inside the previously selected canonical scope.
|
||||
///
|
||||
/// Unlike `save`, this refuses to replace an existing file and revalidates the scope and
|
||||
/// parent directories when the approved write is executed. Ordinary editor saves keep their
|
||||
/// existing behavior. Remote hosts need a matching guarded operation before using this API.
|
||||
pub fn save_new_file_within_scope(
|
||||
&mut self,
|
||||
file_id: FileId,
|
||||
content: String,
|
||||
version: ContentVersion,
|
||||
scope_root: PathBuf,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<(), FileSaveError> {
|
||||
let backend = self
|
||||
.file_state
|
||||
.get(file_id)
|
||||
.ok_or(FileSaveError::NoFilePath(file_id))?;
|
||||
if matches!(backend, FileBackend::Remote { .. }) {
|
||||
return Err(FileSaveError::RemoteError(
|
||||
"Guarded skill creation is not available on remote hosts.".to_string(),
|
||||
));
|
||||
}
|
||||
let file_path = self
|
||||
.file_path(file_id)
|
||||
.ok_or(FileSaveError::NoFilePath(file_id))?;
|
||||
ctx.spawn(
|
||||
async move {
|
||||
guarded_create::write_new_file(&file_path, &scope_root, content.as_bytes()).map_err(
|
||||
|error| FileSaveError::IOError {
|
||||
error,
|
||||
path: file_path,
|
||||
},
|
||||
)
|
||||
},
|
||||
move |me, write_result, ctx| match write_result {
|
||||
Ok(()) => {
|
||||
me.set_version(file_id, version);
|
||||
ctx.emit(FileModelEvent::FileSaved {
|
||||
id: file_id,
|
||||
version,
|
||||
});
|
||||
}
|
||||
Err(error) => ctx.emit(FileModelEvent::FailedToSave {
|
||||
id: file_id,
|
||||
error: Rc::new(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renames a file and also saves its content.
|
||||
// TODO: refactor this against [`FileModel::save`].
|
||||
pub fn rename_and_save(
|
||||
@@ -824,6 +876,56 @@ impl FileModel {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Undoes a successful guarded creation only while its saved contents remain unchanged.
|
||||
pub fn delete_created_file_within_scope(
|
||||
&mut self,
|
||||
file_id: FileId,
|
||||
version: ContentVersion,
|
||||
scope_root: PathBuf,
|
||||
expected_content: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<(), FileSaveError> {
|
||||
let backend = self
|
||||
.file_state
|
||||
.get(file_id)
|
||||
.ok_or(FileSaveError::NoFilePath(file_id))?;
|
||||
if matches!(backend, FileBackend::Remote { .. }) {
|
||||
return Err(FileSaveError::RemoteError(
|
||||
"Guarded skill deletion is not available on remote hosts.".to_string(),
|
||||
));
|
||||
}
|
||||
let file_path = self
|
||||
.file_path(file_id)
|
||||
.ok_or(FileSaveError::NoFilePath(file_id))?;
|
||||
ctx.spawn(
|
||||
async move {
|
||||
guarded_create::remove_created_file(
|
||||
&file_path,
|
||||
&scope_root,
|
||||
expected_content.as_bytes(),
|
||||
)
|
||||
.map_err(|error| FileSaveError::IOError {
|
||||
error,
|
||||
path: file_path,
|
||||
})
|
||||
},
|
||||
move |me, delete_result, ctx| match delete_result {
|
||||
Ok(()) => {
|
||||
me.set_version(file_id, version);
|
||||
ctx.emit(FileModelEvent::FileSaved {
|
||||
id: file_id,
|
||||
version,
|
||||
});
|
||||
}
|
||||
Err(error) => ctx.emit(FileModelEvent::FailedToSave {
|
||||
id: file_id,
|
||||
error: Rc::new(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes the specified file.
|
||||
pub fn delete(
|
||||
&mut self,
|
||||
|
||||
@@ -13,7 +13,9 @@ use ignore::gitignore::Gitignore;
|
||||
use notify_debouncer_full::notify::WatchFilter;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::standing_queries::{StandingQueryDefinitions, StandingQueryResults};
|
||||
use crate::standing_queries::{
|
||||
should_discover_project_context_in_directory, StandingQueryDefinitions, StandingQueryResults,
|
||||
};
|
||||
|
||||
/// Maximum file size allowed for treesitter parsing (3MB).
|
||||
const MAX_FILE_SIZE: usize = 3 * 1000 * 1000;
|
||||
@@ -293,6 +295,10 @@ impl Entry {
|
||||
ignored,
|
||||
is_root: true,
|
||||
});
|
||||
} else if let Some(state) = standing_queries.as_deref_mut() {
|
||||
state
|
||||
.results
|
||||
.discover_in_unloaded_directory(&root_path, state.definitions);
|
||||
}
|
||||
|
||||
while let Some(job) = queue.pop_front() {
|
||||
@@ -314,6 +320,11 @@ impl Entry {
|
||||
}
|
||||
};
|
||||
if !should_expand {
|
||||
if let Some(state) = standing_queries.as_deref_mut() {
|
||||
state
|
||||
.results
|
||||
.discover_in_unloaded_directory(&job.path, state.definitions);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -411,6 +422,11 @@ impl Entry {
|
||||
ignored,
|
||||
is_root: false,
|
||||
});
|
||||
} else if let Some(state) = standing_queries.as_deref_mut() {
|
||||
state.results.discover_in_unloaded_directory(
|
||||
&child_path,
|
||||
state.definitions,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -975,10 +991,9 @@ fn descend_allowlist_matches(suffix: &[Component<'_>]) -> bool {
|
||||
/// Returns whether a repository file watcher should descend into (and register
|
||||
/// a watch on) the directory at `path`.
|
||||
///
|
||||
/// Directories inside `.git/` follow the watcher allowlist, force-included
|
||||
/// paths are always watched even when gitignored, and any other gitignored
|
||||
/// directory is pruned so we don't register watches on `node_modules`, build
|
||||
/// output, vendored deps, etc.
|
||||
/// Directories inside `.git/` follow the watcher allowlist and force-included paths are always
|
||||
/// watched. Registered skill interests additionally search ignored source directories for nested
|
||||
/// providers, while generated dependencies and caches remain pruned.
|
||||
pub fn should_watch_repo_directory(
|
||||
path: &Path,
|
||||
gitignores: &[Gitignore],
|
||||
@@ -992,12 +1007,23 @@ pub fn should_watch_repo_directory(
|
||||
return true;
|
||||
}
|
||||
|
||||
!matches_gitignores(
|
||||
if !matches_gitignores(
|
||||
path,
|
||||
path.is_dir(),
|
||||
gitignores,
|
||||
/* check_ancestors */ true,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let discovers_skills = force_included_paths
|
||||
.iter()
|
||||
.any(|interest| interest.file_name().is_some_and(|name| name == "skills"));
|
||||
discovers_skills
|
||||
&& path
|
||||
.ancestors()
|
||||
.take_while(|ancestor| gitignores.iter().all(|ignore| *ancestor != ignore.path()))
|
||||
.all(should_discover_project_context_in_directory)
|
||||
}
|
||||
|
||||
/// Returns the [`WatchFilter`] used by repository file watchers.
|
||||
@@ -1010,9 +1036,8 @@ pub fn should_watch_repo_directory(
|
||||
/// existing behavior.
|
||||
///
|
||||
/// Descend predicate: see [`should_watch_repo_directory`]. In addition to the
|
||||
/// `.git/` allowlist, it prunes gitignored directories (honoring registered
|
||||
/// force-included paths) so the recursive walk does not register watches on
|
||||
/// gitignored subtrees.
|
||||
/// `.git/` allowlist, it prunes gitignored directories unless skill-context interests require
|
||||
/// searching their source subtrees. Generated dependency/cache trees stay pruned.
|
||||
///
|
||||
/// `gitignores` should be the repo's root + global gitignores (as produced by
|
||||
/// [`gitignores_for_directory`]), matching `Repository::check_gitignore_status`
|
||||
|
||||
@@ -241,14 +241,57 @@ fn should_watch_descends_to_force_included_under_ignored_ancestor() {
|
||||
&gitignores,
|
||||
&force_included
|
||||
));
|
||||
// A sibling ignored dir that is not force-included is still pruned.
|
||||
assert!(!super::should_watch_repo_directory(
|
||||
// Other source directories can also contain a nested skill provider.
|
||||
assert!(super::should_watch_repo_directory(
|
||||
&root.join(".agents/other"),
|
||||
&gitignores,
|
||||
&force_included
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_interests_watch_ignored_source_parents_but_prune_generated_dependencies() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(temp_dir.path()).unwrap();
|
||||
fs::create_dir_all(root.join("app/server/.agents/skills/check")).unwrap();
|
||||
fs::create_dir_all(root.join("node_modules/package")).unwrap();
|
||||
fs::create_dir_all(root.join("target/debug")).unwrap();
|
||||
let gitignores = vec![gitignore_rooted(
|
||||
&root,
|
||||
"app/server/\nnode_modules/\ntarget/\n",
|
||||
)];
|
||||
let interests = vec![std::path::PathBuf::from(".agents/skills")];
|
||||
|
||||
assert!(!super::should_watch_repo_directory(
|
||||
&root.join("app/server"),
|
||||
&gitignores,
|
||||
&[]
|
||||
));
|
||||
for path in [
|
||||
"app/server",
|
||||
"app/server/.agents",
|
||||
"app/server/.agents/skills/check",
|
||||
] {
|
||||
assert!(super::should_watch_repo_directory(
|
||||
&root.join(path),
|
||||
&gitignores,
|
||||
&interests
|
||||
));
|
||||
}
|
||||
for path in [
|
||||
"node_modules",
|
||||
"node_modules/package",
|
||||
"target",
|
||||
"target/debug",
|
||||
] {
|
||||
assert!(!super::should_watch_repo_directory(
|
||||
&root.join(path),
|
||||
&gitignores,
|
||||
&interests
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_watch_handles_nested_ignored_ancestor_with_deeper_force_included() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
@@ -414,6 +457,115 @@ fn standing_queries_report_skills_below_an_ignored_directory() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_project_context_is_discovered_without_materializing_lazy_source_trees() {
|
||||
virtual_fs::VirtualFS::test("nested_context_in_lazy_source", |dirs, mut vfs| {
|
||||
vfs.mkdir("repo/app/server/.agents/skills/run-channel-verifier")
|
||||
.with_files(vec![
|
||||
virtual_fs::Stub::FileWithContent(
|
||||
"repo/app/server/.agents/skills/run-channel-verifier/SKILL.md",
|
||||
"name: run-channel-verifier",
|
||||
),
|
||||
virtual_fs::Stub::FileWithContent(
|
||||
"repo/app/server/AGENTS.md",
|
||||
"Server instructions",
|
||||
),
|
||||
virtual_fs::Stub::FileWithContent("repo/app/server/main.rs", "source"),
|
||||
]);
|
||||
let repo = dirs.tests().join("repo");
|
||||
let skill_path = repo.join("app/server/.agents/skills/run-channel-verifier/SKILL.md");
|
||||
let rule_path = repo.join("app/server/AGENTS.md");
|
||||
let mut definitions = StandingQueryDefinitions::default();
|
||||
definitions.set_project_skill_provider_paths([std::path::PathBuf::from(".agents/skills")]);
|
||||
|
||||
for (gitignore, max_depth, mut remaining_files) in [
|
||||
("app/server/\n", 200, None),
|
||||
("", 1, None),
|
||||
("", 200, Some(0)),
|
||||
] {
|
||||
fs::write(repo.join(".gitignore"), gitignore).unwrap();
|
||||
let mut files = Vec::new();
|
||||
let mut gitignores = Vec::new();
|
||||
let mut results = StandingQueryResults::default();
|
||||
let tree = Entry::build_tree_with_standing_queries(
|
||||
&repo,
|
||||
&mut files,
|
||||
&mut gitignores,
|
||||
remaining_files.as_mut(),
|
||||
super::BuildTreeOptions {
|
||||
max_depth,
|
||||
current_depth: 0,
|
||||
ignored_path_strategy: &IgnoredPathStrategy::IncludeLazy,
|
||||
force_included_paths: &[std::path::PathBuf::from(".agents/skills")],
|
||||
budget_exceeded_behavior: super::BudgetExceededBehavior::StopAndLazyLoad,
|
||||
},
|
||||
&mut results,
|
||||
&definitions,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(results.project_skills().any(|content| {
|
||||
content.path.to_local_path().as_deref() == Some(skill_path.as_path())
|
||||
&& !content.is_directory
|
||||
}));
|
||||
assert!(results.project_rules().any(|content| {
|
||||
content.path.to_local_path().as_deref() == Some(rule_path.as_path())
|
||||
}));
|
||||
assert!(find_entry(&tree, &skill_path).is_none());
|
||||
assert!(find_entry(&tree, &repo.join("app/server/main.rs")).is_none());
|
||||
assert!(files.iter().all(|file| {
|
||||
file.path.to_local_path().as_deref() != Some(skill_path.as_path())
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn lazy_project_context_preserves_linked_skills_without_following_directory_loops() {
|
||||
virtual_fs::VirtualFS::test("lazy_context_linked_skills", |dirs, mut vfs| {
|
||||
vfs.mkdir("repo/app/server/.agents/skills")
|
||||
.mkdir("repo/node_modules/pkg/.agents/skills/ignored")
|
||||
.mkdir("repo/target/.agents/skills/ignored")
|
||||
.mkdir("linked-skill")
|
||||
.with_files(vec![
|
||||
virtual_fs::Stub::FileWithContent("linked-skill/SKILL.md", "linked"),
|
||||
virtual_fs::Stub::FileWithContent(
|
||||
"repo/node_modules/pkg/.agents/skills/ignored/SKILL.md",
|
||||
"dependency",
|
||||
),
|
||||
virtual_fs::Stub::FileWithContent(
|
||||
"repo/target/.agents/skills/ignored/SKILL.md",
|
||||
"generated",
|
||||
),
|
||||
]);
|
||||
let repo = dirs.tests().join("repo");
|
||||
let linked = repo.join("app/server/.agents/skills/linked");
|
||||
std::os::unix::fs::symlink(dirs.tests().join("linked-skill"), &linked).unwrap();
|
||||
std::os::unix::fs::symlink(&repo, repo.join("app/server/loop")).unwrap();
|
||||
|
||||
let mut definitions = StandingQueryDefinitions::default();
|
||||
definitions.set_project_skill_provider_paths([std::path::PathBuf::from(".agents/skills")]);
|
||||
let mut results = StandingQueryResults::default();
|
||||
results.discover_in_unloaded_directory(&repo, &definitions);
|
||||
|
||||
let discovered = results
|
||||
.project_skills()
|
||||
.filter(|content| !content.is_directory)
|
||||
.map(|content| content.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
discovered,
|
||||
vec![
|
||||
galaxy_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&linked.join("SKILL.md")
|
||||
)
|
||||
.unwrap()
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn standing_queries_report_symlinked_skills_without_materializing_symlinked_directories() {
|
||||
|
||||
@@ -466,8 +466,23 @@ impl LocalRepoMetadataModel {
|
||||
let Some(local_repo_path) = repo_path.to_local_path() else {
|
||||
return;
|
||||
};
|
||||
for interest in &self.force_included_paths {
|
||||
let Ok(entries) = std::fs::read_dir(local_repo_path.join(interest)) else {
|
||||
let mut provider_directories = self
|
||||
.force_included_paths
|
||||
.iter()
|
||||
.map(|interest| local_repo_path.join(interest))
|
||||
.collect::<HashSet<_>>();
|
||||
if let Some(results) = self.standing_results.get(repo_path) {
|
||||
provider_directories.extend(results.project_skills().filter_map(|content| {
|
||||
let path = content.path.to_local_path()?;
|
||||
if content.is_directory {
|
||||
Some(path)
|
||||
} else {
|
||||
path.parent()?.parent().map(Path::to_path_buf)
|
||||
}
|
||||
}));
|
||||
}
|
||||
for provider_directory in provider_directories {
|
||||
let Ok(entries) = std::fs::read_dir(provider_directory) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
@@ -1180,6 +1195,8 @@ impl LocalRepoMetadataModel {
|
||||
// is created; insert it as an unloaded placeholder and build
|
||||
// the subtree on demand when the user expands it (see
|
||||
// `load_directory`).
|
||||
standing_results
|
||||
.discover_in_unloaded_directory(path_to_add, standing_query_definitions);
|
||||
mutations.push(FileTreeMutation::AddUnloadedDirectory {
|
||||
path: path_to_add.clone(),
|
||||
is_ignored,
|
||||
|
||||
@@ -1507,6 +1507,56 @@ fn removed_direct_skill_child_refreshes_provider_for_possible_symlink_removal()
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newly_added_nested_skills_refresh_standing_context_for_eager_and_lazy_roots() {
|
||||
VirtualFS::test("new_nested_skill_context", |dirs, mut vfs| {
|
||||
vfs.mkdir("repo/app/server/.agents/skills/run-channel-verifier")
|
||||
.with_files(vec![
|
||||
Stub::FileWithContent(
|
||||
"repo/app/server/.agents/skills/run-channel-verifier/SKILL.md",
|
||||
"name: run-channel-verifier",
|
||||
),
|
||||
Stub::FileWithContent("repo/app/server/AGENTS.md", "Server instructions"),
|
||||
]);
|
||||
let repo = dirs.tests().join("repo");
|
||||
let server = repo.join("app/server");
|
||||
let skill = server.join(".agents/skills/run-channel-verifier/SKILL.md");
|
||||
let rule = server.join("AGENTS.md");
|
||||
let mut definitions = StandingQueryDefinitions::default();
|
||||
definitions.set_project_skill_provider_paths([PathBuf::from(".agents/skills")]);
|
||||
let update = RepoUpdate {
|
||||
added: vec![server],
|
||||
..Default::default()
|
||||
};
|
||||
let (gitignore, _) = Gitignore::new({
|
||||
let gitignore_path = repo.join(".gitignore");
|
||||
std::fs::write(&gitignore_path, "app/server/\n").unwrap();
|
||||
gitignore_path
|
||||
});
|
||||
|
||||
for lazy_load in [false, true] {
|
||||
let (_, discovered, _) = block_on(LocalRepoMetadataModel::compute_file_tree_mutations(
|
||||
&update,
|
||||
std::slice::from_ref(&gitignore),
|
||||
&[PathBuf::from(".agents/skills")],
|
||||
&definitions,
|
||||
lazy_load,
|
||||
));
|
||||
|
||||
assert!(discovered.project_skills().any(|content| {
|
||||
content
|
||||
== &StandingQueryContent::file(
|
||||
StandardizedPath::try_from_local(&skill).unwrap(),
|
||||
)
|
||||
}));
|
||||
assert!(discovered.project_rules().any(|content| {
|
||||
content
|
||||
== &StandingQueryContent::file(StandardizedPath::try_from_local(&rule).unwrap())
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
#[cfg(all(unix, feature = "local_fs"))]
|
||||
#[test]
|
||||
fn added_external_target_skill_symlink_routes_to_lexical_repository() {
|
||||
@@ -1596,18 +1646,18 @@ fn added_external_target_skill_symlink_routes_to_lexical_repository() {
|
||||
}
|
||||
#[cfg(all(unix, feature = "local_fs"))]
|
||||
#[test]
|
||||
fn modified_external_symlink_target_upserts_lexical_project_skill() {
|
||||
fn modified_nested_external_symlink_target_upserts_lexical_project_skill() {
|
||||
VirtualFS::test(
|
||||
"modified_external_symlink_target_upserts_lexical_project_skill",
|
||||
"modified_nested_external_symlink_target_upserts_lexical_project_skill",
|
||||
|dirs, mut vfs| {
|
||||
vfs.mkdir("repo/.agents/skills")
|
||||
vfs.mkdir("repo/app/server/.agents/skills")
|
||||
.mkdir("outside/linked-skill")
|
||||
.with_files(vec![Stub::FileWithContent(
|
||||
"outside/linked-skill/SKILL.md",
|
||||
"linked skill",
|
||||
)]);
|
||||
let repo = dirs.tests().join("repo");
|
||||
let logical_skill_dir = repo.join(".agents/skills/linked-skill");
|
||||
let logical_skill_dir = repo.join("app/server/.agents/skills/linked-skill");
|
||||
let logical_skill_path = logical_skill_dir.join("SKILL.md");
|
||||
let target_skill_path = dirs.tests().join("outside/linked-skill/SKILL.md");
|
||||
std::os::unix::fs::symlink(
|
||||
|
||||
@@ -7,6 +7,28 @@ use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Source directories may be gitignored while still owning project instructions or skills.
|
||||
/// Generated dependency and cache trees are outside that project-context search.
|
||||
pub(crate) fn should_discover_project_context_in_directory(path: &Path) -> bool {
|
||||
!path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
matches!(
|
||||
name,
|
||||
".git"
|
||||
| "node_modules"
|
||||
| "target"
|
||||
| ".venv"
|
||||
| "venv"
|
||||
| ".next"
|
||||
| ".cache"
|
||||
| "__pycache__"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Repository-scoped standing query configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -19,7 +41,12 @@ impl Default for StandingQueryDefinitions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
project_skill_provider_paths: Vec::new(),
|
||||
project_rule_file_names: vec!["WARP.md".to_string(), "AGENTS.md".to_string()],
|
||||
project_rule_file_names: vec![
|
||||
"GALAXY.md".to_string(),
|
||||
"WARP.md".to_string(),
|
||||
"CLAUDE.md".to_string(),
|
||||
"AGENTS.md".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +133,51 @@ impl StandingQueryResults {
|
||||
self.project_rules.iter()
|
||||
}
|
||||
|
||||
/// Discovers context below a directory omitted from the materialized tree because it is
|
||||
/// ignored, too deep, or beyond its file budget. Only context paths are retained; ordinary
|
||||
/// files do not consume the tree budget or become visible entries.
|
||||
pub(crate) fn discover_in_unloaded_directory(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
definitions: &StandingQueryDefinitions,
|
||||
) {
|
||||
if path.is_symlink() {
|
||||
self.record_followed_project_skill_directory(path, definitions);
|
||||
return;
|
||||
}
|
||||
let mut entries = WalkDir::new(path).follow_links(false).into_iter();
|
||||
while let Some(entry) = entries.next() {
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
let path = entry.path();
|
||||
if !entry.file_type().is_dir() {
|
||||
if entry.file_type().is_symlink() && path.is_dir() {
|
||||
self.record_followed_project_skill_directory(path, definitions);
|
||||
} else {
|
||||
self.record_path(path, false, definitions);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !should_discover_project_context_in_directory(path) {
|
||||
entries.skip_current_dir();
|
||||
continue;
|
||||
}
|
||||
|
||||
self.record_path(path, true, definitions);
|
||||
if definitions.is_project_skill_provider_directory(path) {
|
||||
// Only direct skill entries are discoverable. Do not walk scripts, assets,
|
||||
// references, or arbitrary symlink targets beneath a skill installation.
|
||||
if let Ok(skills) = std::fs::read_dir(path) {
|
||||
for skill in skills.flatten() {
|
||||
self.record_followed_project_skill_directory(&skill.path(), definitions);
|
||||
}
|
||||
}
|
||||
entries.skip_current_dir();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a path encountered while traversing the repository.
|
||||
pub(crate) fn record_path(
|
||||
&mut self,
|
||||
|
||||
@@ -46,6 +46,38 @@ fn records_provider_skill_files_and_project_rules() {
|
||||
.any(|content| { content == &StandingQueryContent::file(standardized(&nested_rule)) }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_query_records_supported_project_rule_files() {
|
||||
let definitions = StandingQueryDefinitions::default();
|
||||
let mut results = StandingQueryResults::default();
|
||||
let rule_paths = [
|
||||
repo_path("GALAXY.md"),
|
||||
repo_path("packages/api/WARP.md"),
|
||||
repo_path("app/server/CLAUDE.md"),
|
||||
repo_path("app/client/agents.MD"),
|
||||
];
|
||||
|
||||
for path in &rule_paths {
|
||||
results.record_path(path, false, &definitions);
|
||||
}
|
||||
results.record_path(&repo_path("README.md"), false, &definitions);
|
||||
results.record_path(
|
||||
&repo_path(".agents/skills/check/SKILL.md"),
|
||||
false,
|
||||
&definitions,
|
||||
);
|
||||
results.record_path(&repo_path("generated/AGENTS.md"), true, &definitions);
|
||||
|
||||
let expected = rule_paths
|
||||
.iter()
|
||||
.map(|path| StandingQueryContent::file(standardized(path)))
|
||||
.collect::<HashSet<_>>();
|
||||
assert_eq!(
|
||||
results.project_rules().cloned().collect::<HashSet<_>>(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_removed_direct_skill_child_can_reupsert_provider_for_hydration() {
|
||||
let definitions = definitions();
|
||||
|
||||
Reference in New Issue
Block a user