first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+676 -124
View File
@@ -1,12 +1,20 @@
#![cfg_attr(not(feature = "local_fs"), allow(dead_code))]
use galaxy_util::standardized_path::StandardizedPath;
use ignore::gitignore::Gitignore;
use std::collections::VecDeque;
use std::io;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
#[cfg(feature = "local_fs")]
use std::sync::Arc;
use ignore::gitignore::Gitignore;
#[cfg(feature = "local_fs")]
use notify_debouncer_full::notify::WatchFilter;
use thiserror::Error;
use crate::standing_queries::{StandingQueryDefinitions, StandingQueryResults};
use galaxy_util::standardized_path::StandardizedPath;
/// Maximum file size allowed for treesitter parsing (3MB).
const MAX_FILE_SIZE: usize = 3 * 1000 * 1000;
@@ -29,7 +37,7 @@ pub enum BuildTreeError {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IgnoredPathStrategy {
/// Do not include any ingored files or folders
/// Do not include any ignored files or folders
Exclude,
/// Lazy-load excluded directories
@@ -42,12 +50,38 @@ pub enum IgnoredPathStrategy {
Include,
}
/// What the tree builder does when the per-build file budget is exhausted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BudgetExceededBehavior {
/// Stop descending and leave the remaining directories as unloaded
/// placeholders (lazy-loaded on demand). The build still succeeds with a
/// partial, breadth-first tree. This is the default for the shared file
/// tree, `@`-context, and skill discovery.
StopAndLazyLoad,
/// Abort the build and return [`BuildTreeError::ExceededMaxFileLimit`].
/// Use this for consumers that must not operate on a partial tree — e.g.
/// codebase embedding, where the file limit is an intentional cost cap.
FailFast,
}
/// Filesystem entry.
#[derive(Debug, Clone)]
pub enum Entry {
File(FileMetadata),
Directory(DirectoryEntry),
}
#[derive(Clone, Copy)]
pub(crate) struct BuildTreeOptions<'a> {
pub max_depth: usize,
pub current_depth: usize,
pub ignored_path_strategy: &'a IgnoredPathStrategy,
pub force_included_paths: &'a [PathBuf],
pub budget_exceeded_behavior: BudgetExceededBehavior,
}
struct StandingQueryBuildState<'a> {
results: &'a mut StandingQueryResults,
definitions: &'a StandingQueryDefinitions,
}
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
pub struct FileId(usize);
@@ -85,91 +119,239 @@ impl Entry {
}
/// Builds a tree of entries from a given path, handling gitignored files and directories.
/// After max_depth is reached, all children are lazy-loaded to prevent deeply nested trees.
/// After max_depth is reached, children outside force-included paths are lazy-loaded to
/// prevent deeply nested trees.
/// IgnoredPathStrategy determines what happens when ignored files are encountered.
/// `budget_exceeded_behavior` controls what happens once the file budget is
/// exhausted (see [`BudgetExceededBehavior`]).
#[allow(clippy::too_many_arguments)]
pub fn build_tree(
path: impl Into<PathBuf>,
files: &mut Vec<FileMetadata>,
gitignores: &mut Vec<Gitignore>,
mut remaining_file_quota: Option<&mut usize>,
remaining_file_quota: Option<&mut usize>,
max_depth: usize,
current_depth: usize,
ignored_path_strategy: &IgnoredPathStrategy,
budget_exceeded_behavior: BudgetExceededBehavior,
) -> Result<Self, BuildTreeError> {
let curr_path: PathBuf = path.into();
let is_dir = curr_path.is_dir();
Self::build_tree_with_force_included_paths_and_ancestor(
path,
files,
gitignores,
remaining_file_quota,
BuildTreeOptions {
max_depth,
current_depth,
ignored_path_strategy,
force_included_paths: &[],
budget_exceeded_behavior,
},
false,
None,
)
}
/// Builds the materialized tree and standing results during the same filesystem traversal.
pub(crate) fn build_tree_with_standing_queries(
path: impl Into<PathBuf>,
files: &mut Vec<FileMetadata>,
gitignores: &mut Vec<Gitignore>,
remaining_file_quota: Option<&mut usize>,
options: BuildTreeOptions<'_>,
standing_results: &mut StandingQueryResults,
definitions: &StandingQueryDefinitions,
) -> Result<Self, BuildTreeError> {
let mut standing_queries = StandingQueryBuildState {
results: standing_results,
definitions,
};
Self::build_tree_with_force_included_paths_and_ancestor(
path,
files,
gitignores,
remaining_file_quota,
options,
false,
Some(&mut standing_queries),
)
}
// Only ignore symlinks to directories. Symlinks to files are preserved (e.g. GALAXY.md).
if curr_path.is_symlink() && is_dir {
return Err(BuildTreeError::Symlink);
/// Builds a tree of entries from a given path, eagerly loading any path that
/// matches one of the supplied force-included paths instead of leaving it
/// lazy (see [`BuildTreeOptions::force_included_paths`]).
#[cfg(test)]
pub(crate) fn build_tree_with_force_included_paths(
path: impl Into<PathBuf>,
files: &mut Vec<FileMetadata>,
gitignores: &mut Vec<Gitignore>,
remaining_file_quota: Option<&mut usize>,
options: BuildTreeOptions<'_>,
) -> Result<Self, BuildTreeError> {
Self::build_tree_with_force_included_paths_and_ancestor(
path,
files,
gitignores,
remaining_file_quota,
options,
false,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_tree_with_ignored_ancestor(
path: impl Into<PathBuf>,
files: &mut Vec<FileMetadata>,
gitignores: &mut Vec<Gitignore>,
remaining_file_quota: Option<&mut usize>,
max_depth: usize,
current_depth: usize,
ignored_path_strategy: &IgnoredPathStrategy,
ancestor_is_ignored: bool,
) -> Result<Self, BuildTreeError> {
Self::build_tree_with_force_included_paths_and_ancestor(
path,
files,
gitignores,
remaining_file_quota,
BuildTreeOptions {
max_depth,
current_depth,
ignored_path_strategy,
force_included_paths: &[],
budget_exceeded_behavior: BudgetExceededBehavior::StopAndLazyLoad,
},
ancestor_is_ignored,
None,
)
}
#[allow(clippy::too_many_arguments)]
fn build_tree_with_force_included_paths_and_ancestor(
path: impl Into<PathBuf>,
files: &mut Vec<FileMetadata>,
gitignores: &mut Vec<Gitignore>,
remaining_file_quota: Option<&mut usize>,
options: BuildTreeOptions<'_>,
ancestor_is_ignored: bool,
mut standing_queries: Option<&mut StandingQueryBuildState<'_>>,
) -> Result<Self, BuildTreeError> {
let root_path: PathBuf = path.into();
// Local copy of the file budget. The builder spends it breadth-first;
// once it is exhausted, any remaining directories are left as unloaded
// placeholders (lazy-loaded on demand) instead of aborting the whole
// build. This keeps coverage even and shallow-biased rather than
// collapsing the entire tree to a single level.
let mut quota: Option<usize> = remaining_file_quota.as_deref().copied();
// Arena of partially-built nodes. A child is always discovered (and
// pushed) while expanding its parent, so a child's index is always
// greater than its parent's and the nested tree can be assembled
// bottom-up at the end.
let mut nodes: Vec<Option<NodeBuilder>> = Vec::new();
// Classify the root. Unlike child entries (which are simply omitted when
// ignored/symlinked), a classification failure at the root propagates to
// the caller, preserving existing error behavior.
if let Some(state) = standing_queries.as_deref_mut() {
state
.results
.record_path(&root_path, root_path.is_dir(), state.definitions);
}
let gitignore_path = curr_path.join(".gitignore");
if gitignore_path.exists() {
let (gitignore, _) = Gitignore::new(gitignore_path);
gitignores.push(gitignore);
}
let path_is_ignored = matches_gitignores(
&curr_path,
is_dir,
&*gitignores,
true, /* check_ancestors */
) || is_git_internal_path(&curr_path);
// If we've reached the max depth, force lazy-loading even of non-ignored folders.
let mut lazy_load = current_depth >= max_depth;
if path_is_ignored {
match ignored_path_strategy {
IgnoredPathStrategy::Exclude => {
return Err(BuildTreeError::Ignored);
}
IgnoredPathStrategy::IncludeOnly(patterns) => {
if let Some(file_name) = curr_path.file_name().and_then(|n| n.to_str()) {
if !patterns.iter().any(|pattern| file_name == pattern) {
return Err(BuildTreeError::Ignored);
}
}
}
IgnoredPathStrategy::IncludeLazy => {
lazy_load = true;
}
IgnoredPathStrategy::Include => {}
}
}
if is_dir {
if lazy_load {
return Ok(Self::Directory(DirectoryEntry {
children: vec![],
path: StandardizedPath::from_local_absolute_unchecked(&curr_path),
ignored: path_is_ignored,
loaded: false,
}));
}
// If the path is a directory, process all the children under it.
let entries = std::fs::read_dir(&curr_path)?;
let mut children = Vec::new();
for entry in entries {
if remaining_file_quota
.as_ref()
.is_some_and(|x| **x < children.len())
match evaluate_entry(
&root_path,
gitignores,
&options,
options.current_depth,
ancestor_is_ignored,
)? {
EvaluatedEntry::File { ignored } => {
if quota == Some(0)
&& options.budget_exceeded_behavior == BudgetExceededBehavior::FailFast
{
return Err(BuildTreeError::ExceededMaxFileLimit);
}
let metadata = consume_file(&root_path, ignored, files, &mut quota);
write_back_quota(remaining_file_quota, quota);
Ok(Self::File(metadata))
}
EvaluatedEntry::Directory { ignored, lazy } => {
nodes.push(Some(NodeBuilder::Dir {
path: root_path.clone(),
ignored,
loaded: false,
children: Vec::new(),
}));
if let Some(entry) = match entry {
Ok(entry) => {
let mut queue: VecDeque<DirJob> = VecDeque::new();
if !lazy {
queue.push_back(DirJob {
index: 0,
path: root_path,
depth: options.current_depth,
ignored,
is_root: true,
});
}
while let Some(job) = queue.pop_front() {
// Budget handling. With `StopAndLazyLoad` (the default), once
// the file quota is exhausted we stop expanding directories
// and leave them as unloaded placeholders; directories on the
// path to a force-included path (e.g. skill provider
// directories) are always expanded so discovery-critical
// files stay reachable. With `FailFast` we keep descending
// and abort below as soon as a file would exceed the budget.
let should_expand = match options.budget_exceeded_behavior {
BudgetExceededBehavior::FailFast => true,
BudgetExceededBehavior::StopAndLazyLoad => {
quota.is_none_or(|remaining| remaining > 0)
|| matches_force_included_path(
&job.path,
options.force_included_paths,
)
}
};
if !should_expand {
continue;
}
let entries = match std::fs::read_dir(&job.path) {
Ok(entries) => entries,
Err(e) => {
// Preserve existing behavior: failing to read the
// root directory propagates, while an unreadable
// nested directory is left as an unloaded placeholder.
if job.is_root {
return Err(BuildTreeError::IOError(e));
}
continue;
}
};
if let Some(NodeBuilder::Dir { loaded, .. }) = nodes[job.index].as_mut() {
*loaded = true;
}
let child_depth = job.depth + 1;
for entry in entries {
let Ok(entry) = entry else {
continue;
};
let entry_path = entry.path();
// Skip symlinks to folders before canonicalization to prevent duplicates.
// If it's a symlink to a file, we keep the path as is since canonicalization would
// point its path to the actual file.
// Do not materialize directory symlinks in the canonical tree. Standing
// project-skill queries still follow eligible provider children locally
// and retain their lexical paths in the result set.
let canonical_path = if entry_path.is_symlink() {
if entry_path.is_dir() {
if let Some(state) = standing_queries.as_deref_mut() {
state.results.record_followed_project_skill_directory(
&entry_path,
state.definitions,
);
}
None
} else {
Some(entry_path)
@@ -177,52 +359,71 @@ impl Entry {
} else {
dunce::canonicalize(entry_path).ok()
};
let Some(child_path) = canonical_path else {
continue;
};
if let Some(state) = standing_queries.as_deref_mut() {
state.results.record_path(
&child_path,
child_path.is_dir(),
state.definitions,
);
}
if let Some(canonical_path) = canonical_path {
match Entry::build_tree(
canonical_path,
files,
gitignores,
remaining_file_quota.as_deref_mut(),
max_depth,
current_depth + 1,
ignored_path_strategy,
) {
Ok(entry) => Some(entry),
Err(BuildTreeError::ExceededMaxFileLimit) => {
return Err(BuildTreeError::ExceededMaxFileLimit)
match evaluate_entry(
&child_path,
gitignores,
&options,
child_depth,
job.ignored,
) {
Ok(EvaluatedEntry::File { ignored }) => {
if quota == Some(0)
&& options.budget_exceeded_behavior
== BudgetExceededBehavior::FailFast
{
return Err(BuildTreeError::ExceededMaxFileLimit);
}
Err(_) => None,
let metadata =
consume_file(&child_path, ignored, files, &mut quota);
let child_index = nodes.len();
nodes.push(Some(NodeBuilder::File(metadata)));
push_child(&mut nodes, job.index, child_index);
}
Ok(EvaluatedEntry::Directory { ignored, lazy }) => {
let child_index = nodes.len();
nodes.push(Some(NodeBuilder::Dir {
path: child_path.clone(),
ignored,
loaded: false,
children: Vec::new(),
}));
push_child(&mut nodes, job.index, child_index);
// Lazy directories (past max depth, or ignored
// without a matching force-included path) stay
// unloaded. Everything else is queued for
// expansion, subject to the budget gate above.
if !lazy {
queue.push_back(DirJob {
index: child_index,
path: child_path,
depth: child_depth,
ignored,
is_root: false,
});
}
}
Err(_) => {
// Ignored / excluded / symlinked-directory entries
// are omitted from the tree.
}
} else {
None
}
}
Err(_) => None,
} {
children.push(entry);
}
}
Ok(Self::Directory(DirectoryEntry {
children,
path: StandardizedPath::from_local_absolute_unchecked(&curr_path),
ignored: path_is_ignored,
loaded: true,
}))
} else if curr_path.is_file() {
if let Some(remaining_file_quota) = remaining_file_quota {
if *remaining_file_quota == 0 {
return Err(BuildTreeError::ExceededMaxFileLimit);
}
*remaining_file_quota -= 1
write_back_quota(remaining_file_quota, quota);
Ok(assemble_node(&mut nodes, 0))
}
let metadata = FileMetadata::new(curr_path, path_is_ignored);
files.push(metadata.clone());
Ok(Self::File(metadata))
} else {
Err(BuildTreeError::Symlink)
}
}
@@ -262,8 +463,9 @@ impl Entry {
let mut remaining_file_quota = LAZY_LOAD_FILE_LIMIT;
let mut files = Vec::new();
let ancestor_is_ignored = directory.ignored;
let result = Entry::build_tree(
let result = Entry::build_tree_with_ignored_ancestor(
directory.path.to_local_path_lossy(),
&mut files,
gitignores,
@@ -271,6 +473,7 @@ impl Entry {
1, /* max_depth */
0, /* current_depth */
&IgnoredPathStrategy::Include,
ancestor_is_ignored,
);
result.map(|entry| match entry {
@@ -318,6 +521,162 @@ impl Entry {
}
}
/// A node in the breadth-first build arena. Directory children are referenced by
/// arena index so the nested [`Entry`] tree can be assembled bottom-up.
enum NodeBuilder {
File(FileMetadata),
Dir {
path: PathBuf,
ignored: bool,
loaded: bool,
children: Vec<usize>,
},
}
/// A directory queued for expansion during the breadth-first build.
struct DirJob {
index: usize,
path: PathBuf,
depth: usize,
ignored: bool,
is_root: bool,
}
/// Classification of a single filesystem entry.
enum EvaluatedEntry {
File { ignored: bool },
Directory { ignored: bool, lazy: bool },
}
/// Classifies a single path: rejects directory symlinks, loads any local
/// `.gitignore`, computes gitignore status, and applies the ignored-path
/// strategy. Returns `Err(Ignored)`/`Err(Symlink)` for entries that should be
/// omitted; callers decide whether that is fatal (root) or a skip (child).
fn evaluate_entry(
curr_path: &Path,
gitignores: &mut Vec<Gitignore>,
options: &BuildTreeOptions<'_>,
current_depth: usize,
ancestor_is_ignored: bool,
) -> Result<EvaluatedEntry, BuildTreeError> {
let is_dir = curr_path.is_dir();
// Only ignore symlinks to directories. Symlinks to files are preserved (e.g. WARP.md).
if curr_path.is_symlink() && is_dir {
return Err(BuildTreeError::Symlink);
}
let gitignore_path = curr_path.join(".gitignore");
if gitignore_path.exists() {
let (gitignore, _) = Gitignore::new(gitignore_path);
gitignores.push(gitignore);
}
let path_is_ignored = ancestor_is_ignored
|| is_git_internal_path(curr_path)
|| matches_gitignores(
curr_path,
is_dir,
&*gitignores,
false, /* check_ancestors */
);
let force_included = matches_force_included_path(curr_path, options.force_included_paths);
// If we've reached the max depth, force lazy-loading even of non-ignored folders unless the
// folder is on the path to a force-included subtree.
let mut lazy = current_depth >= options.max_depth && !force_included;
if path_is_ignored {
match options.ignored_path_strategy {
IgnoredPathStrategy::Exclude => return Err(BuildTreeError::Ignored),
IgnoredPathStrategy::IncludeOnly(patterns) => {
if let Some(file_name) = curr_path.file_name().and_then(|n| n.to_str()) {
if !patterns.iter().any(|pattern| file_name == pattern) {
return Err(BuildTreeError::Ignored);
}
}
}
IgnoredPathStrategy::IncludeLazy => {
lazy = !force_included;
}
IgnoredPathStrategy::Include => {}
}
}
if is_dir {
Ok(EvaluatedEntry::Directory {
ignored: path_is_ignored,
lazy,
})
} else if curr_path.is_file() {
Ok(EvaluatedEntry::File {
ignored: path_is_ignored,
})
} else {
Err(BuildTreeError::Symlink)
}
}
/// Records a file: decrements the budget (saturating), constructs metadata, and
/// appends it to the flat `files` list.
fn consume_file(
path: &Path,
ignored: bool,
files: &mut Vec<FileMetadata>,
quota: &mut Option<usize>,
) -> FileMetadata {
if let Some(remaining) = quota.as_mut() {
*remaining = remaining.saturating_sub(1);
}
let metadata = FileMetadata::new(path.to_path_buf(), ignored);
files.push(metadata.clone());
metadata
}
/// Appends `child` to `parent`'s child list in the build arena.
fn push_child(nodes: &mut [Option<NodeBuilder>], parent: usize, child: usize) {
if let Some(NodeBuilder::Dir { children, .. }) = nodes[parent].as_mut() {
children.push(child);
}
}
/// Recursively assembles the nested [`Entry`] tree from the build arena.
/// Recursion depth is normally bounded by `BuildTreeOptions::max_depth`, except for force-included
/// subtrees.
fn assemble_node(nodes: &mut [Option<NodeBuilder>], index: usize) -> Entry {
match nodes[index]
.take()
.expect("each arena node is assembled exactly once")
{
NodeBuilder::File(metadata) => Entry::File(metadata),
NodeBuilder::Dir {
path,
ignored,
loaded,
children,
} => {
let children = children
.into_iter()
.map(|child| assemble_node(nodes, child))
.collect();
Entry::Directory(DirectoryEntry {
path: StandardizedPath::from_local_absolute_unchecked(&path),
children,
ignored,
loaded,
})
}
}
}
/// Writes the remaining budget back into the caller-provided slot, if any.
fn write_back_quota(remaining_file_quota: Option<&mut usize>, quota: Option<usize>) {
if let (Some(slot), Some(value)) = (remaining_file_quota, quota) {
*slot = value;
}
}
pub fn is_git_internal_path(path: &Path) -> bool {
path.components().any(|component| {
if let Component::Normal(name) = component {
@@ -328,6 +687,52 @@ pub fn is_git_internal_path(path: &Path) -> bool {
})
}
/// Returns `true` when `path` is, contains, or lies on the way to one of the
/// `force_included_paths`. Each force-included path is a relative component
/// sequence (e.g. `.agents/skills`) matched against the tail of `path`, so a
/// match also holds for the ancestor prefixes leading to it.
fn matches_force_included_path(path: &Path, force_included_paths: &[PathBuf]) -> bool {
let path_components: Vec<_> = path
.components()
.filter_map(|component| match component {
Component::Normal(name) => Some(name),
Component::Prefix(_)
| Component::RootDir
| Component::CurDir
| Component::ParentDir => None,
})
.collect();
force_included_paths.iter().any(|force_included| {
let force_included_components: Vec<_> = force_included
.components()
.filter_map(|component| match component {
Component::Normal(name) => Some(name),
Component::Prefix(_)
| Component::RootDir
| Component::CurDir
| Component::ParentDir => None,
})
.collect();
if force_included_components.is_empty() {
return false;
}
if path_components
.windows(force_included_components.len())
.any(|window| window == force_included_components.as_slice())
{
return true;
}
(1..force_included_components.len()).any(|prefix_len| {
path_components.len() >= prefix_len
&& path_components[path_components.len() - prefix_len..]
== force_included_components[..prefix_len]
})
})
}
/// Returns true if a path matches any of the gitignores.
///
/// For example, if the directory `/target` is ignored:
@@ -426,6 +831,48 @@ pub(crate) fn is_shared_git_ref(path: &Path) -> bool {
.unwrap_or(false)
}
/// Returns `true` for loose remote-tracking refs under the shared `.git`
/// directory, e.g. `.git/refs/remotes/origin/main`.
pub(crate) fn is_remote_tracking_ref(path: &Path) -> bool {
if extract_worktree_git_dir(path).is_some() {
return false;
}
let components: Vec<_> = path.components().collect();
let Some(git_index) = components.iter().position(|c| c.as_os_str() == ".git") else {
return false;
};
let after_git = &components[git_index + 1..];
after_git.len() >= 4
&& after_git[0].as_os_str() == "refs"
&& after_git[1].as_os_str() == "remotes"
}
/// Returns true for Git files that can change the current branch's tracked
/// upstream ref.
pub(crate) fn is_tracking_state_git_file(path: &Path) -> bool {
let Some(suffix) = git_suffix_components(path) else {
return false;
};
suffix.len() == 1
&& matches!(
suffix[0].as_os_str().to_str(),
Some("HEAD" | "config" | "config.worktree")
)
}
/// Returns true for `.git/config` in the shared Git directory.
pub(crate) fn is_common_git_config(path: &Path) -> bool {
if extract_worktree_git_dir(path).is_some() {
return false;
}
let components: Vec<_> = path.components().collect();
let Some(git_index) = components.iter().position(|c| c.as_os_str() == ".git") else {
return false;
};
let after_git = &components[git_index + 1..];
after_git.len() == 1 && after_git[0].as_os_str() == "config"
}
/// Returns true for `.git/HEAD` and `.git/refs/heads/*`
/// (and their worktree equivalents `.git/worktrees/*/HEAD`, etc.).
pub(crate) fn is_commit_related_git_file(path: &Path) -> bool {
@@ -452,33 +899,138 @@ pub(crate) fn is_index_lock_file(path: &Path) -> bool {
/// Determines if a git-related path should be ignored by the filesystem watcher.
///
/// Uses an allowlist approach: only commit-related files (HEAD, refs/heads/*)
/// and the index lock file are allowed through. Everything else inside `.git/`
/// is ignored.
/// Uses an allowlist approach: only commit-related files (HEAD, refs/heads/*),
/// loose remote-tracking refs, tracked-upstream state files, and the index lock
/// file are allowed through. Everything else inside `.git/` is ignored.
pub fn should_ignore_git_path(path: &Path) -> bool {
if !is_git_internal_path(path) {
return false; // Not a git path, don't ignore
}
// Ignore everything inside .git/ except the allowlisted patterns.
!is_commit_related_git_file(path) && !is_index_lock_file(path)
!is_commit_related_git_file(path)
&& !is_index_lock_file(path)
&& !is_remote_tracking_ref(path)
&& !is_tracking_state_git_file(path)
}
pub fn path_passes_filters(path: &Path, gitignores: &[Gitignore]) -> bool {
let to_check_path = if path.exists() {
match dunce::canonicalize(path) {
Ok(canonical_path) => canonical_path,
Err(_) => return false,
/// Returns `true` when the directory at `path` should be registered for watching.
/// Specifically for prefixes that lead to an allowlisted file and `false` for everything else inside `.git/`.
pub fn should_watch_directory_in_git_path(path: &Path) -> bool {
if !is_git_internal_path(path) {
return true;
}
// Worktree paths: `.git/worktrees/<name>/...` only descends along the
// path needed to reach the allowlisted children (HEAD, index.lock,
// config.worktree, refs/heads/*, refs/remotes/<r>/*).
if let Some(worktree_dir) = extract_worktree_git_dir(path) {
// `path` is either the worktree gitdir itself or something under it.
// Anything up to and including `.git/worktrees/<name>` must
// be descended into so we can reach children.
if path == worktree_dir || worktree_dir.starts_with(path) {
return true;
}
} else {
path.to_path_buf()
// Inside `.git/worktrees/<name>/...`. Apply the same allowlist logic as for the shared `.git/`.
let Some(suffix) = git_suffix_components(path) else {
return false;
};
return descend_allowlist_matches(&suffix);
}
// Common `.git/` directory: allow descending along the path to
// `.git/`, `.git/refs/heads/`, `.git/refs/remotes/<remote>/`, and
// `.git/worktrees/<name>/`.
let Some(suffix) = git_suffix_components(path) else {
// Path is `.git/` itself — needed so we can reach allowlisted children.
return true;
};
descend_allowlist_matches(&suffix)
}
/// Returns `true` for an in-`.git/` directory suffix that lies on the way to an allowlisted file.
/// `suffix` is the component sequence after the `.git` component (worktree indirection already stripped),
/// so e.g. `.git/worktrees/<name>/refs/heads` is seen here as just `["refs", "heads"]`.
///
/// Only the first two components are inspected:
/// - `top_level_dir` is the directory immediately under `.git/` (e.g. `refs`, `objects`, `worktrees`)
/// and decides which subtree we're descending into.
/// - `refs_subdir` is meaningful only when `top_level_dir == "refs"`, where it distinguishes
/// the watched ref subtrees (`heads`, `remotes`) from pruned ones (`tags`, etc.).
fn descend_allowlist_matches(suffix: &[Component<'_>]) -> bool {
let top_level_dir = suffix.first().and_then(|c| c.as_os_str().to_str());
let refs_subdir = suffix.get(1).and_then(|c| c.as_os_str().to_str());
match top_level_dir {
// `.git/refs`, `.git/refs/heads[/...]`, `.git/refs/remotes[/<r>[/...]]`.
// `.git/refs/tags/*` and other refs subtrees stay pruned.
Some("refs") => matches!(refs_subdir, None | Some("heads") | Some("remotes")),
// Worktree dispatcher — needed to reach `.git/worktrees/<name>/...`.
Some("worktrees") => true,
// All other `.git/` subdirectories (objects, hooks, logs, info, lfs, …) are pruned.
Some(_) => false,
// `.git/` itself — descend so allowlisted children stay reachable.
None => true,
}
}
/// 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.
pub fn should_watch_repo_directory(
path: &Path,
gitignores: &[Gitignore],
force_included_paths: &[PathBuf],
) -> bool {
if is_git_internal_path(path) {
return should_watch_directory_in_git_path(path);
}
if matches_force_included_path(path, force_included_paths) {
return true;
}
!matches_gitignores(
&to_check_path,
to_check_path.is_dir(),
path,
path.is_dir(),
gitignores,
true, /* check_ancestors */
) && !should_ignore_git_path(&to_check_path)
/* check_ancestors */ true,
)
}
/// Returns the [`WatchFilter`] used by repository file watchers.
///
/// Emit predicate: forwards events for everything outside `.git/` plus the
/// allowlisted files inside `.git/` (HEAD, refs/heads/*, index.lock,
/// config, config.worktree, refs/remotes/<r>/*, and worktree equivalents).
/// Gitignored files that live directly in a watched (non-ignored) directory
/// are still emitted here and tagged `is_ignored` downstream, preserving
/// 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.
///
/// `gitignores` should be the repo's root + global gitignores (as produced by
/// [`gitignores_for_directory`]), matching `Repository::check_gitignore_status`
/// so descend decisions and the downstream `is_ignored` tagging stay
/// consistent. Nested per-directory `.gitignore` files are not consulted here
/// (same limitation as the existing tagging), which can only cause us to
/// over-watch, never to miss events.
#[cfg(feature = "local_fs")]
pub fn repo_watch_filter(
gitignores: Vec<Gitignore>,
force_included_paths: Vec<PathBuf>,
) -> WatchFilter {
let should_watch =
move |path: &Path| should_watch_repo_directory(path, &gitignores, &force_included_paths);
WatchFilter::with_filter(
Arc::new(should_watch),
Arc::new(|path: &Path| !should_ignore_git_path(path)),
)
}
/// Determines whether a file should be parsed by a treesitter query. For now the main criteria is it shouldn't
@@ -614,5 +1166,5 @@ impl DirectoryEntry {
}
#[cfg(test)]
#[path = "entry_test.rs"]
#[path = "entry_tests.rs"]
mod tests;
-415
View File
@@ -1,415 +0,0 @@
use super::path_passes_filters;
use ignore::gitignore::Gitignore;
use virtual_fs::{Stub, VirtualFS};
#[cfg(unix)]
#[test]
fn test_path_passes_filters_unix() {
VirtualFS::test("test_path_passes_filters", |dirs, mut sandbox| {
sandbox.mkdir("my_repo");
sandbox.mkdir("my_repo/.git");
sandbox.mkdir("my_repo/.git/refs");
sandbox.mkdir("my_repo/.git/refs/heads");
sandbox.mkdir("my_repo/src");
sandbox.mkdir("my_repo/target");
sandbox.mkdir("my_repo/target/debug");
sandbox.mkdir("outside_of_codebase");
sandbox.with_files(vec![
Stub::EmptyFile("my_repo/README.txt"),
Stub::EmptyFile("my_repo/.git/blob.txt"),
Stub::EmptyFile("my_repo/.git/HEAD"),
Stub::EmptyFile("my_repo/.git/refs/heads/main"),
Stub::EmptyFile("my_repo/.git/refs/heads/feature-branch"),
Stub::EmptyFile("my_repo/src/main.rs"),
Stub::EmptyFile("my_repo/target/debug/a.out"),
Stub::EmptyFile("outside_of_codebase/text.txt"),
]);
sandbox.with_files(vec![Stub::FileWithContent("my_repo/.gitignore", "target")]);
let test_gitignore_entry = dirs.tests().join("my_repo/.gitignore");
let gitignores = vec![Gitignore::new(test_gitignore_entry).0];
// Do NOT ignore a file that does not exist (for deletions)
assert!(path_passes_filters(
dirs.tests().join("my_repo/does_not_exist.txt").as_path(),
&gitignores
));
assert!(path_passes_filters(
dirs.tests().join("my_repo/src").as_path(),
&gitignores
));
assert!(path_passes_filters(
dirs.tests().join("my_repo/src/main.rs").as_path(),
&gitignores
));
assert!(path_passes_filters(
dirs.tests().join("outside_of_codebase/text.txt").as_path(),
&gitignores
));
// Allow .git internal files that provide useful signals
assert!(path_passes_filters(
dirs.tests().join("my_repo/.git/HEAD").as_path(),
&gitignores
));
assert!(path_passes_filters(
dirs.tests().join("my_repo/.git/refs/heads").as_path(),
&gitignores
));
assert!(path_passes_filters(
dirs.tests().join("my_repo/.git/refs/heads/main").as_path(),
&gitignores
));
assert!(path_passes_filters(
dirs.tests()
.join("my_repo/.git/refs/heads/feature-branch")
.as_path(),
&gitignores
));
// Non-allowlisted .git/ internal files are filtered out
assert!(!path_passes_filters(
dirs.tests().join("my_repo/.git/index").as_path(),
&gitignores
));
assert!(!path_passes_filters(
dirs.tests().join("my_repo/.git/blob.txt").as_path(),
&gitignores
));
// .git directory itself is still ignored
assert!(!path_passes_filters(
dirs.tests().join("my_repo/.git").as_path(),
&gitignores
));
// Ignore .gitignored paths and their children.
assert!(!path_passes_filters(
dirs.tests().join("my_repo/target/").as_path(),
&gitignores
));
assert!(!path_passes_filters(
dirs.tests().join("my_repo/target/debug").as_path(),
&gitignores
));
assert!(!path_passes_filters(
dirs.tests().join("my_repo/target/debug/a.out").as_path(),
&gitignores
));
// Ignore a .gitignored file that does not exist (for deletions)
assert!(!path_passes_filters(
&dirs.tests().join("my_repo/target/does_not_exist.txt"),
&gitignores
));
// Ensure paths are canonicalized before being matched against gitignores.
assert!(path_passes_filters(
dirs.tests()
.join("outside_of_codebase/../my_repo/README.txt")
.as_path(),
&gitignores
));
assert!(!path_passes_filters(
dirs.tests()
.join("outside_of_codebase/../my_repo/target/debug/a.out")
.as_path(),
&gitignores
));
});
}
#[cfg_attr(
windows,
ignore = "TODO(CODE-312): issue with Gitignore matching on Windows"
)]
#[cfg(windows)]
#[test]
fn test_path_passes_filters_windows() {
VirtualFS::test("test_path_passes_filters", |dirs, mut sandbox| {
sandbox.mkdir("my_repo");
sandbox.mkdir(r"my_repo\.git");
sandbox.mkdir(r"my_repo\.git\refs");
sandbox.mkdir(r"my_repo\.git\refs\heads");
sandbox.mkdir(r"my_repo\src");
sandbox.mkdir(r"my_repo\target");
sandbox.mkdir(r"my_repo\target\debug");
sandbox.mkdir("outside_of_codebase");
sandbox.with_files(vec![
Stub::EmptyFile(r"my_repo\README.txt"),
Stub::EmptyFile(r"my_repo\.git\blob.txt"),
Stub::EmptyFile(r"my_repo\.git\HEAD"),
Stub::EmptyFile(r"my_repo\.git\refs\heads\main"),
Stub::EmptyFile(r"my_repo\.git\refs\heads\feature-branch"),
Stub::EmptyFile(r"my_repo\src\main.rs"),
Stub::EmptyFile(r"my_repo\target\debug\a.out"),
Stub::EmptyFile(r"outside_of_codebase\text.txt"),
]);
sandbox.with_files(vec![Stub::FileWithContent(r"my_repo\.gitignore", "target")]);
let test_gitignore_entry = dirs.tests().join(r"my_repo\.gitignore");
let gitignores = vec![Gitignore::new(test_gitignore_entry).0];
assert!(path_passes_filters(
dirs.tests().join(r"my_repo\src").as_path(),
&gitignores
));
assert!(path_passes_filters(
dirs.tests().join(r"my_repo\src\main.rs").as_path(),
&gitignores
));
assert!(path_passes_filters(
dirs.tests().join(r"outside_of_codebase\text.txt").as_path(),
&gitignores
));
// Allow .git internal files that provide useful signals
assert!(path_passes_filters(
dirs.tests().join(r"my_repo\.git\HEAD").as_path(),
&gitignores
));
assert!(path_passes_filters(
dirs.tests().join(r"my_repo\.git\refs\heads").as_path(),
&gitignores
));
assert!(path_passes_filters(
dirs.tests().join(r"my_repo\.git\refs\heads\main").as_path(),
&gitignores
));
assert!(path_passes_filters(
dirs.tests()
.join(r"my_repo\.git\refs\heads\feature-branch")
.as_path(),
&gitignores
));
// .git directory itself is still ignored
assert!(!path_passes_filters(
dirs.tests().join(r"my_repo\.git").as_path(),
&gitignores
));
// Ignore .gitignored paths and their children.
assert!(!path_passes_filters(
dirs.tests().join(r"my_repo\target").as_path(),
&gitignores
));
assert!(!path_passes_filters(
dirs.tests().join(r"my_repo\target\debug").as_path(),
&gitignores
));
assert!(!path_passes_filters(
dirs.tests().join(r"my_repo\target\debug\a.out").as_path(),
&gitignores
));
// Ensure paths are canonicalized before being matched against gitignores.
assert!(path_passes_filters(
dirs.tests()
.join(r"outside_of_codebase\..\my_repo\README.txt")
.as_path(),
&gitignores
));
assert!(!path_passes_filters(
dirs.tests()
.join(r"outside_of_codebase\..\my_repo\target\debug\a.out")
.as_path(),
&gitignores
));
});
}
#[test]
fn test_git_path_filtering_allowlist() {
use super::{is_commit_related_git_file, is_index_lock_file, should_ignore_git_path};
use std::path::Path;
// Non-git paths should not be ignored
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/src/main.rs"
)));
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/README.md"
)));
// .git directory itself should be ignored
assert!(should_ignore_git_path(Path::new("/home/user/project/.git")));
// Allowlisted: commit-related files are NOT ignored
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/HEAD"
)));
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/refs/heads/main"
)));
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/refs/heads/feature-branch"
)));
// Allowlisted: index.lock is NOT ignored
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/index.lock"
)));
// Everything else in .git/ IS ignored
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/index"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/config"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/COMMIT_EDITMSG"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/FETCH_HEAD"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/ORIG_HEAD"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/refs/tags/v1.0"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/refs/remotes/origin/main"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/objects/abc123"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/hooks/pre-commit"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/logs/HEAD"
)));
// Worktree paths: allowlisted patterns under .git/worktrees/<name>/
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/worktrees/my-wt/HEAD"
)));
assert!(!should_ignore_git_path(Path::new(
"/home/user/project/.git/worktrees/my-wt/index.lock"
)));
// Non-allowlisted worktree paths are still ignored
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/worktrees/my-wt/index"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/worktrees/my-wt/COMMIT_EDITMSG"
)));
// worktrees dir itself (no content after worktree name) is ignored
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/worktrees"
)));
assert!(should_ignore_git_path(Path::new(
"/home/user/project/.git/worktrees/my-wt"
)));
// is_commit_related_git_file
assert!(is_commit_related_git_file(Path::new("/repo/.git/HEAD")));
assert!(is_commit_related_git_file(Path::new(
"/repo/.git/refs/heads/main"
)));
assert!(is_commit_related_git_file(Path::new(
"/repo/.git/worktrees/wt/HEAD"
)));
assert!(!is_commit_related_git_file(Path::new(
"/repo/.git/index.lock"
)));
assert!(!is_commit_related_git_file(Path::new(
"/repo/.git/refs/tags/v1"
)));
// is_index_lock_file
assert!(is_index_lock_file(Path::new("/repo/.git/index.lock")));
assert!(is_index_lock_file(Path::new(
"/repo/.git/worktrees/wt/index.lock"
)));
assert!(!is_index_lock_file(Path::new("/repo/.git/HEAD")));
assert!(!is_index_lock_file(Path::new("/repo/.git/index")));
// Test Windows-style paths (only on Windows, as path parsing is platform-specific)
#[cfg(windows)]
{
assert!(!should_ignore_git_path(Path::new(
r"C:\Users\user\project\.git\HEAD"
)));
assert!(!should_ignore_git_path(Path::new(
r"C:\Users\user\project\.git\index.lock"
)));
assert!(should_ignore_git_path(Path::new(
r"C:\Users\user\project\.git\index"
)));
}
}
#[test]
fn test_is_shared_git_ref() {
use super::is_shared_git_ref;
use std::path::Path;
// Shared refs — broadcast to all repos
assert!(is_shared_git_ref(Path::new("/repo/.git/refs/heads/main")));
assert!(is_shared_git_ref(Path::new(
"/repo/.git/refs/heads/feature"
)));
// Repo-specific — NOT shared
assert!(!is_shared_git_ref(Path::new("/repo/.git/HEAD")));
assert!(!is_shared_git_ref(Path::new("/repo/.git/index.lock")));
// Worktree paths — NOT shared
assert!(!is_shared_git_ref(Path::new(
"/repo/.git/worktrees/foo/HEAD"
)));
assert!(!is_shared_git_ref(Path::new(
"/repo/.git/worktrees/foo/refs/heads/main"
)));
// Other .git internals — NOT shared
assert!(!is_shared_git_ref(Path::new("/repo/.git/refs/tags/v1")));
assert!(!is_shared_git_ref(Path::new(
"/repo/.git/refs/remotes/origin/main"
)));
assert!(!is_shared_git_ref(Path::new("/repo/.git/config")));
// Not a git path at all
assert!(!is_shared_git_ref(Path::new("/repo/src/main.rs")));
}
#[test]
fn test_extract_worktree_git_dir() {
use super::extract_worktree_git_dir;
use std::path::{Path, PathBuf};
// Standard worktree path extracts the per-worktree gitdir
assert_eq!(
extract_worktree_git_dir(Path::new("/repo/.git/worktrees/foo/HEAD")),
Some(PathBuf::from("/repo/.git/worktrees/foo"))
);
assert_eq!(
extract_worktree_git_dir(Path::new("/repo/.git/worktrees/bar/index.lock")),
Some(PathBuf::from("/repo/.git/worktrees/bar"))
);
// Non-worktree paths return None
assert_eq!(extract_worktree_git_dir(Path::new("/repo/.git/HEAD")), None);
assert_eq!(
extract_worktree_git_dir(Path::new("/repo/.git/refs/heads/main")),
None
);
assert_eq!(
extract_worktree_git_dir(Path::new("/repo/src/main.rs")),
None
);
// Edge case: not enough depth after worktrees/
assert_eq!(
extract_worktree_git_dir(Path::new("/repo/.git/worktrees")),
None
);
assert_eq!(
extract_worktree_git_dir(Path::new("/repo/.git/worktrees/foo")),
None
);
}
File diff suppressed because it is too large Load Diff
+26 -20
View File
@@ -1,15 +1,21 @@
mod file_tree_state;
use std::sync::Arc;
use ignore::gitignore::Gitignore;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui_core::ModelHandle;
use crate::file_tree_store::file_tree_state::FileTreeMapStore;
use crate::{BuildTreeError, Entry, FileId, FileMetadata, Repository};
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::ModelHandle;
use ignore::gitignore::Gitignore;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct FileTreeEntry {
state_map: FileTreeMapStore,
// Wrapped in an `Arc` so cloning a `FileTreeEntry` is O(1) (a refcount
// bump) instead of deep-copying the whole tree. Mutations go through
// `Arc::make_mut`, which copies-on-write only when the store is actually
// shared with another holder (e.g. the model and a view).
state_map: Arc<FileTreeMapStore>,
root_path: Arc<StandardizedPath>,
}
@@ -38,7 +44,7 @@ impl FileTreeEntry {
}
pub fn rename_path(&mut self, path: &StandardizedPath, new_path: &StandardizedPath) -> bool {
self.state_map.rename_path(path, new_path)
Arc::make_mut(&mut self.state_map).rename_path(path, new_path)
}
pub fn load_at_path(
@@ -46,11 +52,11 @@ impl FileTreeEntry {
path: &StandardizedPath,
gitignores: &mut Vec<Gitignore>,
) -> Result<(), BuildTreeError> {
self.state_map.load_at_path(path, gitignores)
Arc::make_mut(&mut self.state_map).load_at_path(path, gitignores)
}
pub fn insert_entry_at_path(&mut self, path: Arc<StandardizedPath>, entry: Entry) {
self.state_map.insert_entry_at_path(path, entry);
Arc::make_mut(&mut self.state_map).insert_entry_at_path(path, entry);
}
pub fn child_paths(
@@ -61,16 +67,16 @@ impl FileTreeEntry {
}
pub fn get_mut(&mut self, path: &StandardizedPath) -> Option<&mut FileTreeEntryState> {
self.state_map.get_mut(path)
Arc::make_mut(&mut self.state_map).get_mut(path)
}
pub fn remove(&mut self, path: &StandardizedPath) {
self.state_map.remove(path);
Arc::make_mut(&mut self.state_map).remove(path);
}
pub fn new_for_directory(root_path: Arc<StandardizedPath>) -> Self {
Self {
state_map: FileTreeMapStore::new_for_directory(root_path.clone()),
state_map: Arc::new(FileTreeMapStore::new_for_directory(root_path.clone())),
root_path,
}
}
@@ -82,8 +88,10 @@ impl FileTreeEntry {
parent_path: &StandardizedPath,
target_path: &StandardizedPath,
) -> Option<&mut FileTreeEntryState> {
// `contains_child` is a read, so check it before `make_mut` to avoid
// a copy-on-write when the child already exists.
if self.state_map.contains_child(parent_path, target_path) {
return self.state_map.get_mut(target_path);
return Arc::make_mut(&mut self.state_map).get_mut(target_path);
}
// Child not found, create new directory entry
@@ -93,9 +101,9 @@ impl FileTreeEntry {
loaded: false,
});
self.state_map
.insert_child(Arc::new(parent_path.clone()), new_entry);
self.state_map.get_mut(target_path)
let store = Arc::make_mut(&mut self.state_map);
store.insert_child(Arc::new(parent_path.clone()), new_entry);
store.get_mut(target_path)
}
pub fn find_parent_directory(&self, path: &StandardizedPath) -> Option<Arc<StandardizedPath>> {
@@ -130,8 +138,7 @@ impl FileTreeEntry {
return None;
};
self.state_map
.insert_child(Arc::new(parent_path.clone()), new_entry)
Arc::make_mut(&mut self.state_map).insert_child(Arc::new(parent_path.clone()), new_entry)
}
pub fn insert_child_state(
@@ -139,8 +146,7 @@ impl FileTreeEntry {
parent_path: &StandardizedPath,
child_state: FileTreeEntryState,
) -> Option<Arc<StandardizedPath>> {
self.state_map
.insert_child(Arc::new(parent_path.clone()), child_state)
Arc::make_mut(&mut self.state_map).insert_child(Arc::new(parent_path.clone()), child_state)
}
/// Ensures all ancestor directories between root and `target_parent`
@@ -339,7 +345,7 @@ pub struct FileTreeDirectoryEntryState {
impl From<Entry> for FileTreeEntry {
fn from(value: Entry) -> Self {
let root_path = Arc::new(value.path().clone());
let state_map = FileTreeMapStore::from(value);
let state_map = Arc::new(FileTreeMapStore::from(value));
FileTreeEntry {
state_map,
@@ -1,12 +1,13 @@
use crate::file_tree_store::FileTreeEntry;
use crate::file_tree_store::{FileTreeDirectoryEntryState, FileTreeEntryState};
use crate::{BuildTreeError, DirectoryEntry, Entry};
use galaxy_util::standardized_path::StandardizedPath;
use ignore::gitignore::Gitignore;
use std::collections::{HashMap, HashSet};
use std::iter;
use std::sync::Arc;
use ignore::gitignore::Gitignore;
use galaxy_util::standardized_path::StandardizedPath;
use crate::file_tree_store::{FileTreeDirectoryEntryState, FileTreeEntry, FileTreeEntryState};
use crate::{BuildTreeError, DirectoryEntry, Entry};
#[derive(Debug, Clone)]
pub(super) struct FileTreeMapStore {
state_map: HashMap<Arc<StandardizedPath>, FileTreeEntryState>,
@@ -202,9 +203,14 @@ impl FileTreeMapStore {
pub fn insert_entry_at_path(&mut self, path: Arc<StandardizedPath>, entry: Entry) {
let child_entry_map = FileTreeEntry::from(entry);
self.state_map.extend(child_entry_map.state_map.state_map);
// The child was just constructed, so its `Arc` is unique and
// `try_unwrap` avoids a clone; fall back to cloning only if it is
// somehow shared.
let child_store =
Arc::try_unwrap(child_entry_map.state_map).unwrap_or_else(|arc| (*arc).clone());
self.state_map.extend(child_store.state_map);
self.parent_to_child_map
.extend(child_entry_map.state_map.parent_to_child_map);
.extend(child_store.parent_to_child_map);
// ATODO test this
if let Some(parent) = self.parent_directory(&path) {
@@ -1,7 +1,9 @@
use std::sync::Arc;
use galaxy_util::standardized_path::StandardizedPath;
use crate::entry::{DirectoryEntry, Entry, FileId, FileMetadata};
use crate::file_tree_store::{FileTreeEntry, FileTreeEntryState};
use galaxy_util::standardized_path::StandardizedPath;
use std::sync::Arc;
fn std_path(s: &str) -> StandardizedPath {
StandardizedPath::try_new(s).expect("test path should be valid")
@@ -8,6 +8,16 @@
use galaxy_util::standardized_path::StandardizedPath;
use crate::entry::{DirectoryEntry, Entry, FileMetadata};
use crate::standing_queries::StandingQueryResultsDelta;
/// Describes how a file-tree entry update should be interpreted by consumers.
#[derive(Debug, Clone)]
pub enum MetadataUpdateType {
/// The updated entry was replaced without a precise metadata delta.
/// Consumers should refresh any derived state conservatively.
FullReplace,
/// The updated entry includes a precise incremental metadata delta.
IncrementalUpdate(RepoMetadataUpdate),
}
/// Mirrors `RepoMetadataUpdate` proto.
///
@@ -22,6 +32,8 @@ pub struct RepoMetadataUpdate {
pub remove_entries: Vec<StandardizedPath>,
/// Subtree patches to add or replace in the tree.
pub update_entries: Vec<FileTreeEntryUpdate>,
/// Standing query changes synchronized with this tree change.
pub standing_results_delta: StandingQueryResultsDelta,
}
/// Mirrors `FileTreeEntry` proto.
@@ -1,9 +1,11 @@
use std::path::{Path, PathBuf};
use galaxy_util::standardized_path::StandardizedPath;
use crate::entry::{DirectoryEntry, Entry, FileId, FileMetadata};
use crate::file_tree_store::{FileTreeEntry, FileTreeEntryState};
use crate::file_tree_update::*;
use crate::local_model::LocalRepoMetadataModel;
use galaxy_util::standardized_path::StandardizedPath;
use std::path::{Path, PathBuf};
// ── Helpers ──────────────────────────────────────────────────────────
@@ -146,7 +148,6 @@ fn apply_mutations_generates_update_for_remove() {
#[test]
fn apply_mutations_generates_update_for_add_file() {
use crate::local_model::FileTreeMutation;
let initial = dir("/repo", vec![dir("/repo/src", vec![])]);
let mut tree = build_tree_from_entry(initial);
@@ -177,7 +178,6 @@ fn apply_mutations_generates_update_for_add_file() {
#[test]
fn apply_mutations_generates_update_for_add_directory_subtree() {
use crate::local_model::FileTreeMutation;
let subtree = dir(
"/repo/src/components",
@@ -212,11 +212,10 @@ fn apply_mutations_generates_update_for_add_directory_subtree() {
#[test]
fn apply_mutations_generates_update_for_add_empty_directory() {
use crate::local_model::FileTreeMutation;
let initial = dir("/repo", vec![dir("/repo/src", vec![])]);
let mut tree = build_tree_from_entry(initial);
let mutations = vec![FileTreeMutation::AddEmptyDirectory {
let mutations = vec![FileTreeMutation::AddUnloadedDirectory {
path: mutation_path("/repo/src/empty"),
is_ignored: true,
}];
@@ -237,7 +236,6 @@ fn apply_mutations_generates_update_for_add_empty_directory() {
#[test]
fn apply_mutations_generates_update_for_mixed_mutations() {
use crate::local_model::FileTreeMutation;
let initial = dir("/repo", vec![file("/repo/old.rs")]);
let mut tree = build_tree_from_entry(initial);
@@ -248,7 +246,7 @@ fn apply_mutations_generates_update_for_mixed_mutations() {
is_ignored: false,
extension: Some("rs".to_string()),
},
FileTreeMutation::AddEmptyDirectory {
FileTreeMutation::AddUnloadedDirectory {
path: mutation_path("/repo/new_dir"),
is_ignored: false,
},
@@ -264,7 +262,6 @@ fn apply_mutations_generates_update_for_mixed_mutations() {
#[test]
fn apply_mutations_returns_none_when_emit_updates_is_false() {
use crate::local_model::FileTreeMutation;
let initial = dir("/repo", vec![file("/repo/old.rs")]);
let mut tree = build_tree_from_entry(initial);
@@ -313,6 +310,7 @@ fn apply_complete_update_adds_files_and_directories() {
}),
],
}],
standing_results_delta: Default::default(),
};
tree.apply_repo_metadata_update(&update);
@@ -359,6 +357,7 @@ fn apply_update_with_removals_and_additions() {
ignored: false,
})],
}],
standing_results_delta: Default::default(),
};
tree.apply_repo_metadata_update(&update);
@@ -389,6 +388,7 @@ fn apply_incomplete_update_missing_children_subtree() {
loaded: false,
})],
}],
standing_results_delta: Default::default(),
};
tree.apply_repo_metadata_update(&update);
@@ -417,6 +417,7 @@ fn apply_incomplete_update_missing_children_subtree() {
ignored: false,
})],
}],
standing_results_delta: Default::default(),
};
tree.apply_repo_metadata_update(&followup);
@@ -444,6 +445,7 @@ fn apply_incomplete_update_missing_parent_from_undelivered_page() {
ignored: false,
})],
}],
standing_results_delta: Default::default(),
};
tree.apply_repo_metadata_update(&update);
@@ -475,7 +477,6 @@ fn apply_incomplete_update_missing_parent_from_undelivered_page() {
#[test]
fn round_trip_apply_mutations_then_apply_update_produces_equivalent_tree() {
use crate::local_model::FileTreeMutation;
let server_tree_entry = dir(
"/repo",
@@ -545,7 +546,6 @@ fn round_trip_apply_mutations_then_apply_update_produces_equivalent_tree() {
#[test]
fn lazy_load_filters_mutations_for_unloaded_parents() {
use crate::local_model::FileTreeMutation;
let initial = Entry::Directory(DirectoryEntry {
path: std_path("/repo"),
+22 -18
View File
@@ -2,16 +2,13 @@
//!
//! This crate provides utilities for managing repository metadata, including file trees,
//! gitignore processing, and filesystem watching capabilities.s
use std::{
borrow::Borrow,
path::{Path, PathBuf},
};
use std::borrow::Borrow;
use std::path::{Path, PathBuf};
use galaxy_util::standardized_path::StandardizedPath;
use thiserror::Error;
#[cfg(not(target_family = "wasm"))]
use galaxyui::SingletonEntity;
use galaxyui_core::SingletonEntity;
/// Errors that can occur when working with repository metadata.
#[derive(Error, Debug)]
@@ -28,6 +25,12 @@ pub enum RepoMetadataError {
BuildTree(BuildTreeError),
#[error("Failed to start watcher: {0}")]
WatcherError(#[from] anyhow::Error),
#[error("Repository not indexed")]
RepositoryNotIndexed,
#[error("Repository indexing in progress")]
RepositoryIndexingPending,
#[error("Repository indexing failed")]
RepositoryIndexingFailed,
}
// Re-export the modules
pub mod entry;
@@ -38,42 +41,43 @@ pub mod remote_model;
pub mod repositories;
pub mod repository;
pub mod repository_identifier;
pub mod standing_queries;
mod telemetry;
pub mod watcher;
pub mod wrapper_model;
pub use entry::{
gitignores_for_directory, matches_gitignores, path_passes_filters, should_ignore_git_path,
BuildTreeError, DirectoryEntry, Entry, FileId, FileMetadata,
gitignores_for_directory, matches_gitignores, should_ignore_git_path, BuildTreeError,
DirectoryEntry, Entry, FileId, FileMetadata,
};
// Re-export the local model's event under its original name for backward compatibility.
pub use local_model::RepositoryMetadataEvent;
pub use repository::Repository;
pub use watcher::{DirectoryWatcher, RepositoryUpdate, TargetFile};
#[cfg(not(target_family = "wasm"))]
pub fn is_in_repo(path: &str, app: &galaxyui::AppContext) -> bool {
pub fn is_in_repo(path: &str, app: &galaxyui_core::AppContext) -> bool {
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use crate::repositories::DetectedRepositories;
DetectedRepositories::as_ref(app)
.get_root_for_path(std::path::Path::new(path))
.get_root_for_path(&LocalOrRemotePath::Local(std::path::PathBuf::from(path)))
.is_some()
}
#[cfg(target_family = "wasm")]
pub fn is_in_repo(_path: &str, _app: &galaxyui::AppContext) -> bool {
pub fn is_in_repo(_path: &str, _app: &galaxyui_core::AppContext) -> bool {
false
}
pub use file_tree_store::FileTreeEntry;
pub use local_model::{LocalRepoMetadataModel, RepoContent};
// New types.
pub use file_tree_update::RepoMetadataUpdate;
pub use file_tree_update::{MetadataUpdateType, RepoMetadataUpdate};
pub use local_model::{LocalRepoMetadataModel, RepoContent, RepoContents};
pub use remote_model::RemoteRepoMetadataModel;
pub use repository_identifier::{RemoteRepositoryIdentifier, RepositoryIdentifier};
pub use standing_queries::{
StandingQueryContent, StandingQueryDefinitions, StandingQueryResults, StandingQueryResultsDelta,
};
pub use wrapper_model::{RepoMetadataEvent, RepoMetadataModel};
/// A wrapper around PathBuf that ensures the path is canonicalized.
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -14,8 +14,8 @@ mod tests {
use futures::channel::oneshot;
use futures::executor::block_on;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::r#async::FutureExt as _;
use galaxyui::App;
use galaxyui_core::r#async::FutureExt as _;
use galaxyui_core::App;
use ignore::gitignore::Gitignore;
use std::cell::RefCell;
use std::collections::HashMap;
@@ -1038,7 +1038,6 @@ Thumbs.db
#[test]
fn test_canonicalized_path_functionality() {
use galaxy_util::standardized_path::StandardizedPath;
VirtualFS::test("canonicalized_path_test", |dirs, mut vfs| {
let repo_path = dirs.tests();
@@ -1107,7 +1106,6 @@ Thumbs.db
#[test]
fn test_repository_operations_with_standardized_paths() {
use galaxy_util::standardized_path::StandardizedPath;
VirtualFS::test("repo_canonicalized_test", |dirs, mut vfs| {
let test_root = dirs.tests();
@@ -1220,7 +1218,6 @@ Thumbs.db
#[test]
fn test_standardized_path_edge_cases() {
use galaxy_util::standardized_path::StandardizedPath;
VirtualFS::test("canonicalized_edge_cases", |dirs, mut vfs| {
let test_root = dirs.tests();
File diff suppressed because it is too large Load Diff
+117 -22
View File
@@ -7,15 +7,17 @@
use std::collections::HashMap;
use std::sync::Arc;
use futures::future::{self, BoxFuture, FutureExt as _};
use galaxy_core::HostId;
use galaxyui::ModelContext;
use crate::file_tree_store::{FileTreeEntry, FileTreeState};
use crate::file_tree_update::RepoMetadataUpdate;
use crate::local_model::{GetContentsArgs, IndexedRepoState, RepoContent};
use crate::repository_identifier::RemoteRepositoryIdentifier;
use galaxyui_core::ModelContext;
use super::local_model::collect_contents_recursive;
use crate::file_tree_store::{FileTreeEntry, FileTreeState};
use crate::file_tree_update::{MetadataUpdateType, RepoMetadataUpdate};
use crate::local_model::{GetContentsArgs, IndexedRepoState, RepoContents};
use crate::repository_identifier::RemoteRepositoryIdentifier;
use crate::standing_queries::{StandingQueryResults, StandingQueryResultsDelta};
use crate::RepoMetadataError;
/// Events emitted by the [`RemoteRepoMetadataModel`].
#[derive(Debug)]
@@ -29,7 +31,16 @@ pub enum RemoteRepositoryMetadataEvent {
ids: Vec<RemoteRepositoryIdentifier>,
},
/// The file tree entry for a remote repository was updated.
FileTreeEntryUpdated { id: RemoteRepositoryIdentifier },
FileTreeEntryUpdated {
id: RemoteRepositoryIdentifier,
/// Specifies whether this event contains a precise delta or an opaque whole-entry
/// replacement.
update_type: MetadataUpdateType,
},
StandingQueryResultsUpdated {
id: RemoteRepositoryIdentifier,
delta: StandingQueryResultsDelta,
},
}
/// Client-side model for remote repository metadata.
@@ -42,12 +53,25 @@ pub enum RemoteRepositoryMetadataEvent {
/// wrapper rather than using this type directly.
pub struct RemoteRepoMetadataModel {
repositories: HashMap<RemoteRepositoryIdentifier, IndexedRepoState>,
standing_results: HashMap<RemoteRepositoryIdentifier, StandingQueryResults>,
}
impl RemoteRepoMetadataModel {
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
Self {
repositories: HashMap::new(),
standing_results: HashMap::new(),
}
}
/// Returns a future that resolves once remote repository indexing reaches a terminal state.
///
/// Callers should check [`Self::repository_state`] after awaiting this future to see whether
/// indexing succeeded or failed.
pub fn repository_indexed(&self, id: &RemoteRepositoryIdentifier) -> BoxFuture<'static, ()> {
match self.repositories.get(id) {
Some(state) => state.wait_until_indexed(),
None => future::ready(()).boxed(),
}
}
@@ -57,10 +81,17 @@ impl RemoteRepoMetadataModel {
pub fn get_repository(&self, id: &RemoteRepositoryIdentifier) -> Option<&FileTreeState> {
match self.repositories.get(id)? {
IndexedRepoState::Indexed(state) => Some(state),
IndexedRepoState::Pending | IndexedRepoState::Failed(_) => None,
IndexedRepoState::Pending(_) | IndexedRepoState::Failed(_) => None,
}
}
pub fn standing_query_results(
&self,
id: &RemoteRepositoryIdentifier,
) -> Option<&StandingQueryResults> {
self.standing_results.get(id)
}
/// Returns whether the given remote repository is indexed.
pub fn has_repository(&self, id: &RemoteRepositoryIdentifier) -> bool {
matches!(
@@ -75,23 +106,40 @@ impl RemoteRepoMetadataModel {
}
/// Returns repository contents for the specified remote repository.
///
/// The number of returned entries is capped; when the repository contains
/// more matching entries, the result is truncated and
/// [`RepoContents::truncated`] is set to `true`.
///
/// Returns an error if the repository is not indexed, indexing is pending, or indexing failed.
pub fn get_repo_contents(
&self,
id: &RemoteRepositoryIdentifier,
args: GetContentsArgs,
) -> Option<Vec<RepoContent<'_>>> {
let state = match self.repositories.get(id)? {
IndexedRepoState::Indexed(state) => state,
IndexedRepoState::Pending | IndexedRepoState::Failed(_) => return None,
) -> Result<RepoContents<'_>, RepoMetadataError> {
let state = match self.repositories.get(id) {
Some(IndexedRepoState::Indexed(state)) => state,
Some(IndexedRepoState::Pending(_)) => {
return Err(RepoMetadataError::RepositoryIndexingPending);
}
Some(IndexedRepoState::Failed(_)) => {
return Err(RepoMetadataError::RepositoryIndexingFailed);
}
None => {
return Err(RepoMetadataError::RepositoryNotIndexed);
}
};
let mut contents = Vec::new();
collect_contents_recursive(
let truncated = collect_contents_recursive(
&state.entry,
state.entry.root_directory(),
&mut contents,
&args,
);
Some(contents)
Ok(RepoContents {
contents,
truncated,
})
}
/// Returns all tracked remote repository identifiers, including those in
@@ -110,8 +158,7 @@ impl RemoteRepoMetadataModel {
state: FileTreeState,
ctx: &mut ModelContext<Self>,
) {
self.repositories
.insert(id.clone(), IndexedRepoState::Indexed(state));
self.replace_repository_state(id.clone(), IndexedRepoState::Indexed(state));
ctx.emit(RemoteRepositoryMetadataEvent::RepositoryUpdated { id });
}
@@ -121,7 +168,7 @@ impl RemoteRepoMetadataModel {
id: &RemoteRepositoryIdentifier,
ctx: &mut ModelContext<Self>,
) {
if self.repositories.remove(id).is_some() {
if self.remove_repository_state(id).is_some() {
ctx.emit(RemoteRepositoryMetadataEvent::RepositoryRemoved { id: id.clone() });
}
}
@@ -135,7 +182,10 @@ impl RemoteRepoMetadataModel {
) {
if let Some(IndexedRepoState::Indexed(state)) = self.repositories.get_mut(id) {
state.entry = entry;
ctx.emit(RemoteRepositoryMetadataEvent::FileTreeEntryUpdated { id: id.clone() });
ctx.emit(RemoteRepositoryMetadataEvent::FileTreeEntryUpdated {
id: id.clone(),
update_type: MetadataUpdateType::FullReplace,
});
}
}
@@ -155,6 +205,9 @@ impl RemoteRepoMetadataModel {
entry.apply_repo_metadata_update(update);
let state = FileTreeState::from_file_tree_entry(entry);
let id = RemoteRepositoryIdentifier::new(host_id, update.repo_path.clone());
let mut standing_results = StandingQueryResults::default();
standing_results.apply_delta(&update.standing_results_delta);
self.standing_results.insert(id.clone(), standing_results);
self.insert_repository(id, state, ctx);
}
@@ -198,20 +251,62 @@ impl RemoteRepoMetadataModel {
if let Some(IndexedRepoState::Indexed(state)) = self.repositories.get_mut(&id) {
state.entry.apply_repo_metadata_update(update);
ctx.emit(RemoteRepositoryMetadataEvent::FileTreeEntryUpdated { id });
ctx.emit(RemoteRepositoryMetadataEvent::FileTreeEntryUpdated {
id: id.clone(),
update_type: MetadataUpdateType::IncrementalUpdate(update.clone()),
});
}
if !update.standing_results_delta.is_empty() {
self.standing_results
.entry(id.clone())
.or_default()
.apply_delta(&update.standing_results_delta);
ctx.emit(RemoteRepositoryMetadataEvent::StandingQueryResultsUpdated {
id,
delta: update.standing_results_delta.clone(),
});
}
}
}
impl galaxyui::Entity for RemoteRepoMetadataModel {
impl galaxyui_core::Entity for RemoteRepoMetadataModel {
type Event = RemoteRepositoryMetadataEvent;
}
impl RemoteRepoMetadataModel {
fn replace_repository_state(
&mut self,
id: RemoteRepositoryIdentifier,
state: IndexedRepoState,
) -> Option<IndexedRepoState> {
let previous = self.repositories.insert(id, state);
if let Some(previous) = &previous {
previous.complete_if_pending();
}
previous
}
fn remove_repository_state(
&mut self,
id: &RemoteRepositoryIdentifier,
) -> Option<IndexedRepoState> {
self.standing_results.remove(id);
let previous = self.repositories.remove(id);
if let Some(previous) = &previous {
previous.complete_if_pending();
}
previous
}
}
#[cfg(any(test, feature = "test-util"))]
impl RemoteRepoMetadataModel {
/// Insert a repository state directly for testing purposes.
pub fn insert_test_state(&mut self, id: RemoteRepositoryIdentifier, state: FileTreeState) {
self.repositories
.insert(id, IndexedRepoState::Indexed(state));
self.replace_repository_state(id, IndexedRepoState::Indexed(state));
}
}
#[cfg(test)]
#[path = "remote_model_tests.rs"]
mod tests;
@@ -0,0 +1,53 @@
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui_core::App;
use super::*;
use crate::StandingQueryContent;
fn path(path: &str) -> StandardizedPath {
StandardizedPath::try_new(path).unwrap()
}
#[test]
fn snapshot_and_incremental_update_maintain_remote_standing_results() {
App::test((), |mut app| async move {
let model = app.add_model(RemoteRepoMetadataModel::new);
let host = HostId::new("remote-host".to_string());
let repo_path = path("/repo");
let skill = StandingQueryContent::file(path("/repo/.agents/skills/review/SKILL.md"));
let rule = StandingQueryContent::file(path("/repo/WARP.md"));
let id = RemoteRepositoryIdentifier::new(host.clone(), repo_path.clone());
let snapshot = RepoMetadataUpdate {
repo_path: repo_path.clone(),
remove_entries: Vec::new(),
update_entries: Vec::new(),
standing_results_delta: StandingQueryResultsDelta {
upserted_project_skills: vec![skill.clone()],
..Default::default()
},
};
model.update(&mut app, |model, ctx| {
model.insert_from_snapshot(host.clone(), &snapshot, ctx);
});
let incremental = RepoMetadataUpdate {
repo_path,
remove_entries: Vec::new(),
update_entries: Vec::new(),
standing_results_delta: StandingQueryResultsDelta {
removed_project_skills: vec![skill],
upserted_project_rules: vec![rule.clone()],
..Default::default()
},
};
model.update(&mut app, |model, ctx| {
model.apply_incremental_update(&host, &incremental, ctx);
});
model.read(&app, |model, _ctx| {
let results = model.standing_query_results(&id).unwrap();
assert!(results.project_skills().next().is_none());
assert!(results.project_rules().any(|content| content == &rule));
});
});
}
+160 -43
View File
@@ -1,17 +1,19 @@
use std::path::Path;
use std::{collections::HashSet, future::Future, path::PathBuf};
use std::collections::HashSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use galaxy_util::standardized_path::StandardizedPath;
#[cfg(test)]
use galaxyui::r#async::FutureId;
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle};
use futures::future::{ready, Either};
#[cfg(test)]
use virtual_fs::{Stub, VirtualFS};
use galaxy_util::host_id::HostId;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxy_util::remote_path::{RemoteNavigationResult, RemotePath};
use galaxy_util::standardized_path::StandardizedPath;
#[cfg(test)]
use galaxyui_core::r#async::FutureId;
use galaxyui_core::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
use crate::DirectoryWatcher;
use crate::Repository;
use futures::future::{ready, Either};
use galaxyui::SingletonEntity;
use crate::{DirectoryWatcher, Repository};
/// Indicates why a repository detection event was emitted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -36,17 +38,57 @@ pub enum DetectedRepositoriesEvent {
/// Tracks the detected _git_ repositories during the lifetime of the application. This should be the canonical source of truth for repository information.
#[derive(Default)]
pub struct DetectedRepositories {
repository_roots: HashSet<StandardizedPath>,
repository_roots: HashSet<LocalOrRemotePath>,
#[cfg(test)]
/// List of spawned background tasks, for testing.
spawned_futures: Vec<FutureId>,
}
impl DetectedRepositories {
/// Detects the git repository root for the given working directory.
///
/// For **local sessions**, pass `None` for `remote_detect` — this delegates
/// to the local filesystem detection path.
///
/// For **remote sessions**, pass `Some(future)` where the future resolves
/// with `(RemotePath, is_git)` from the remote server. The future is
/// typically obtained from `RemoteServerManager::navigate_to_directory`.
/// When `is_git` is true, the result is `Some(LocalOrRemotePath::Remote(...))`.
///
/// This design avoids a circular dependency between `repo_metadata` and
/// `remote_server` — the caller in `app/` constructs the remote future
/// and injects it here.
pub fn detect_possible_git_repo<
F: Future<Output = Option<RemoteNavigationResult>> + 'static,
>(
&mut self,
active_directory: &str,
source: RepoDetectionSource,
remote_detect: Option<F>,
ctx: &mut ModelContext<Self>,
) -> impl Future<Output = Option<LocalOrRemotePath>> {
match remote_detect {
None => {
// Local detection path.
let fut = self.detect_possible_local_git_repo(active_directory, source, ctx);
Either::Left(async move { fut.await.map(LocalOrRemotePath::Local) })
}
Some(remote_fut) => Either::Right(async move {
match remote_fut.await {
Some(RemoteNavigationResult {
remote_path,
is_git: true,
}) => Some(LocalOrRemotePath::Remote(remote_path)),
_ => None,
}
}),
}
}
/// Given the active directory pwd, kick off a background job to detect the git project root and emit an event
/// to interested listeners.
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
pub fn detect_possible_git_repo(
pub fn detect_possible_local_git_repo(
&mut self,
active_directory: &str,
source: RepoDetectionSource,
@@ -61,19 +103,28 @@ impl DetectedRepositories {
return Either::Right(ready(None));
};
if let Some(repository) = self.repository_roots.get(&path) {
if let Some(local_path) = repository.to_local_path() {
if let Some(repository) =
DirectoryWatcher::as_ref(ctx).get_watched_directory_for_path(&local_path)
{
ctx.emit(DetectedRepositoriesEvent::DetectedGitRepo {
repository: repository.clone(),
source,
});
let local_key = path.to_local_path().map(LocalOrRemotePath::Local);
if let Some(ref key) = local_key {
if self.repository_roots.contains(key) {
if let Some(local_path) = path.to_local_path() {
if let Some(repository) = DirectoryWatcher::as_ref(ctx)
.get_watched_directory_for_path(&local_path)
{
ctx.emit(DetectedRepositoriesEvent::DetectedGitRepo {
repository: repository.clone(),
source,
});
// Watcher is alive — use the cached result.
return Either::Right(ready(path.to_local_path()));
}
// Watcher was cleaned up (e.g. diff state model dropped
// and recreated). Fall through to the full scan which
// will re-register the watcher.
} else {
return Either::Right(ready(path.to_local_path()));
}
}
return Either::Right(ready(repository.to_local_path()));
};
}
let local_path_for_search = path.to_local_path();
let (tx, rx) = oneshot::channel::<Option<PathBuf>>();
@@ -92,7 +143,10 @@ impl DetectedRepositories {
.as_ref()
.and_then(|path| StandardizedPath::from_local_canonicalized(path).ok())
{
me.repository_roots.insert(repo_root_path.clone());
if let Some(local_path) = repo_root_path.to_local_path() {
me.repository_roots
.insert(LocalOrRemotePath::Local(local_path));
}
let external_git_dir = StandardizedPath::from_local_canonicalized(
info.git_dir_path.as_path(),
@@ -152,35 +206,95 @@ impl DetectedRepositories {
&self.spawned_futures
}
/// Given a path, return its corresdponding watched repository, if any.
pub fn get_watched_repo_for_path(
/// Given a local path, return its corresponding watched repository, if any.
pub fn get_local_watched_repo_for_path(
&self,
path: &Path,
ctx: &AppContext,
) -> Option<ModelHandle<Repository>> {
let root = self.get_root_for_path(path)?;
DirectoryWatcher::as_ref(ctx).get_watched_directory_for_path(&root)
let root = self.get_root_for_path(&LocalOrRemotePath::Local(path.to_path_buf()))?;
let local_path = root.to_local_path()?;
DirectoryWatcher::as_ref(ctx).get_watched_directory_for_path(local_path)
}
/// Given a path, return its corresponding repo root. Note that this does not run the check
/// against the actual file system. Instead it checks against our cached path to root mapping.
pub fn get_root_for_path(&self, path: &Path) -> Option<PathBuf> {
let std_path = StandardizedPath::from_local_canonicalized(path).ok()?;
let repo = self.find_repository_root(&std_path)?;
repo.to_local_path()
/// Given a local or remote path, return its corresponding repo root.
///
/// No git detection is performed; roots are looked up in our cached
/// path-to-root mapping. Note that for local paths this still hits the
/// file system: the path is canonicalized first (resolving symlinks and
/// requiring it to exist) so it can match the canonicalized cached roots.
/// If the input is already canonicalized, prefer
/// [`Self::get_root_for_canonical_path`], which performs no I/O.
pub fn get_root_for_path(&self, path: &LocalOrRemotePath) -> Option<LocalOrRemotePath> {
match path {
LocalOrRemotePath::Local(local_path) => {
let std_path = StandardizedPath::from_local_canonicalized(local_path).ok()?;
self.find_local_repository_root(&std_path)
}
LocalOrRemotePath::Remote(remote_path) => self.find_remote_repository_root(remote_path),
}
}
/// Find the repository that contains the given path, if any.
fn find_repository_root(&self, path: &StandardizedPath) -> Option<StandardizedPath> {
let mut current = Some(path.clone());
while let Some(ancestor) = current {
if let Some(repo) = self.repository_roots.get(&ancestor) {
return Some(repo.clone());
/// Given a local or remote path, return its corresponding repo root.
/// This does not run the check against the actual file system.
/// Instead it checks against our cached path to root mapping.
///
/// Local paths must already be canonicalized (symlinks resolved); they
/// are only normalized here, without any filesystem I/O. A
/// non-canonical path may fail to match the canonicalized cached roots
/// — use [`Self::get_root_for_path`] for such paths instead.
pub fn get_root_for_canonical_path(
&self,
path: &LocalOrRemotePath,
) -> Option<LocalOrRemotePath> {
match path {
LocalOrRemotePath::Local(local_path) => {
let std_path = StandardizedPath::try_from_local(local_path).ok()?;
self.find_local_repository_root(&std_path)
}
LocalOrRemotePath::Remote(remote_path) => self.find_remote_repository_root(remote_path),
}
}
/// Find the local repository that contains the given path, if any.
fn find_local_repository_root(&self, path: &StandardizedPath) -> Option<LocalOrRemotePath> {
for ancestor in path.ancestors() {
if let Some(local_path) = ancestor.to_local_path() {
let key = LocalOrRemotePath::Local(local_path);
if self.repository_roots.contains(&key) {
return Some(key);
}
}
current = ancestor.parent();
}
None
}
/// Find the remote repository that contains the given path, if any.
fn find_remote_repository_root(&self, remote_path: &RemotePath) -> Option<LocalOrRemotePath> {
for ancestor in remote_path.path.ancestors() {
let candidate =
LocalOrRemotePath::Remote(RemotePath::new(remote_path.host_id.clone(), ancestor));
if self.repository_roots.contains(&candidate) {
return Some(candidate);
}
}
None
}
/// Register a remote repository root discovered via the remote server.
pub fn register_remote_repo_root(&mut self, remote_path: RemotePath) {
self.repository_roots
.insert(LocalOrRemotePath::Remote(remote_path));
}
/// Remove all cached repository roots for a given remote host.
/// Call on `HostDisconnected` to prevent stale entries.
pub fn remove_roots_for_host(&mut self, host_id: &HostId) {
self.repository_roots.retain(|entry| match entry {
LocalOrRemotePath::Local(_) => true,
LocalOrRemotePath::Remote(remote) => remote.host_id != *host_id,
});
}
}
impl Entity for DetectedRepositories {
@@ -192,9 +306,12 @@ impl SingletonEntity for DetectedRepositories {}
/// Test helpers: direct mutation of internal state.
#[cfg(any(test, feature = "test-util"))]
impl DetectedRepositories {
/// Insert a repository root path directly, bypassing git detection.
/// Insert a local repository root path directly, bypassing git detection.
pub fn insert_test_repo_root(&mut self, path: StandardizedPath) {
self.repository_roots.insert(path);
if let Some(local_path) = path.to_local_path() {
self.repository_roots
.insert(LocalOrRemotePath::Local(local_path));
}
}
}
+24 -15
View File
@@ -1,13 +1,15 @@
use std::fs;
use crate::repositories::{stub_git_repository, RepoDetectionSource};
use crate::{repositories::DetectedRepositories, watcher::DirectoryWatcher};
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::App;
use virtual_fs::{Stub, VirtualFS};
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui_core::App;
use crate::repositories::{stub_git_repository, DetectedRepositories, RepoDetectionSource};
use crate::watcher::DirectoryWatcher;
#[test]
fn test_detect_possible_git_repo_non_existent_directory() {
fn test_detect_possible_local_git_repo_non_existent_directory() {
VirtualFS::test("detect_non_existent", |dirs, _vfs| {
let non_existent_path = dirs.tests().join("non_existent_directory");
@@ -16,7 +18,7 @@ fn test_detect_possible_git_repo_non_existent_directory() {
let repo_handle = app.add_model(|_| DetectedRepositories::default());
repo_handle.update(&mut app, |watcher, ctx| {
std::mem::drop(watcher.detect_possible_git_repo(
std::mem::drop(watcher.detect_possible_local_git_repo(
&non_existent_path.to_string_lossy(),
RepoDetectionSource::TerminalNavigation,
ctx,
@@ -33,7 +35,7 @@ fn test_detect_possible_git_repo_non_existent_directory() {
}
#[test]
fn test_detect_possible_git_repo_not_a_git_repo() {
fn test_detect_possible_local_git_repo_not_a_git_repo() {
VirtualFS::test("detect_not_git", |dirs, mut vfs| {
// Create a regular directory structure without .git
vfs.mkdir("regular_dir/subdir").with_files(vec![
@@ -48,7 +50,7 @@ fn test_detect_possible_git_repo_not_a_git_repo() {
let repo_handle = app.add_model(|_| DetectedRepositories::default());
repo_handle.update(&mut app, |watcher, ctx| {
std::mem::drop(watcher.detect_possible_git_repo(
std::mem::drop(watcher.detect_possible_local_git_repo(
&regular_dir.to_string_lossy(),
RepoDetectionSource::TerminalNavigation,
ctx,
@@ -66,14 +68,15 @@ fn test_detect_possible_git_repo_not_a_git_repo() {
let regular_canonical =
StandardizedPath::from_local_canonicalized(&regular_dir).unwrap();
repo_handle.read(&app, |watcher, _ctx| {
assert!(!watcher.repository_roots.contains(&regular_canonical));
let key = LocalOrRemotePath::Local(regular_canonical.to_local_path().unwrap());
assert!(!watcher.repository_roots.contains(&key));
});
});
});
}
#[test]
fn test_detect_possible_git_repo_nested_repo_created_after_parent_registration() {
fn test_detect_possible_local_git_repo_nested_repo_created_after_parent_registration() {
VirtualFS::test("detect_nested_repo", |dirs, mut vfs| {
// Create a parent git repository structure
stub_git_repository(&mut vfs, "parent_repo");
@@ -91,7 +94,7 @@ fn test_detect_possible_git_repo_nested_repo_created_after_parent_registration()
// Now, try to detect the nested git repo.
repo_handle
.update(&mut app, |repo, ctx| {
std::mem::drop(repo.detect_possible_git_repo(
std::mem::drop(repo.detect_possible_local_git_repo(
&parent_repo.to_string_lossy(),
RepoDetectionSource::TerminalNavigation,
ctx,
@@ -104,7 +107,9 @@ fn test_detect_possible_git_repo_nested_repo_created_after_parent_registration()
// Verify parent is registered
repo_handle.read(&app, |repo, _ctx| {
assert!(repo
.get_root_for_path(parent_canonical_path.to_local_path().as_deref().unwrap())
.get_root_for_path(&LocalOrRemotePath::Local(
parent_canonical_path.to_local_path().unwrap(),
))
.is_some());
});
@@ -115,7 +120,7 @@ fn test_detect_possible_git_repo_nested_repo_created_after_parent_registration()
// Now, try to detect the nested git repo.
repo_handle
.update(&mut app, |repo, ctx| {
std::mem::drop(repo.detect_possible_git_repo(
std::mem::drop(repo.detect_possible_local_git_repo(
&nested_project.to_string_lossy(),
RepoDetectionSource::TerminalNavigation,
ctx,
@@ -131,11 +136,15 @@ fn test_detect_possible_git_repo_nested_repo_created_after_parent_registration()
repo_handle.read(&app, |repo, _ctx| {
// Parent should still be registered
assert!(repo
.get_root_for_path(parent_canonical_path.to_local_path().as_deref().unwrap())
.get_root_for_path(&LocalOrRemotePath::Local(
parent_canonical_path.to_local_path().unwrap(),
))
.is_some());
// Nested project should now also be registered as its own repo
assert!(repo
.get_root_for_path(nested_canonical_path.to_local_path().as_deref().unwrap())
.get_root_for_path(&LocalOrRemotePath::Local(
nested_canonical_path.to_local_path().unwrap(),
))
.is_some());
});
+199 -54
View File
@@ -1,29 +1,30 @@
use std::collections::HashMap;
use std::future::Future;
#[cfg(feature = "local_fs")]
use std::path::{Component, Path, PathBuf};
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[cfg(feature = "local_fs")]
use std::path::Path;
use futures::future::ready;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::r#async::{BoxFuture, SpawnedFutureHandle};
use galaxyui_core::r#async::{BoxFuture, SpawnedFutureHandle};
#[cfg(feature = "local_fs")]
use galaxyui::SingletonEntity;
use galaxyui::{Entity, ModelContext, ModelHandle};
use galaxyui_core::SingletonEntity;
use galaxyui_core::{Entity, ModelContext, ModelHandle};
#[cfg(feature = "local_fs")]
use ignore::gitignore::Gitignore;
#[cfg(feature = "local_fs")]
#[cfg(feature = "local_fs")]
use crate::watcher::DirectoryWatcher;
use crate::watcher::TaskQueue;
#[cfg(feature = "local_fs")]
use crate::{
entry::{matches_gitignores, should_ignore_git_path},
gitignores_for_directory,
};
use crate::{watcher::TaskQueue, RepoMetadataError, RepositoryUpdate};
use crate::{RepoMetadataError, RepositoryUpdate};
/// Trait for entities that want to subscribe to repository file changes.
pub trait RepositorySubscriber: Send + Sync {
@@ -74,10 +75,46 @@ pub struct Repository {
/// Cached gitignore patterns for this repository.
#[cfg(feature = "local_fs")]
gitignores: Vec<Gitignore>,
/// Cached loose remote-tracking ref tracked by the active branch.
#[cfg(feature = "local_fs")]
tracked_remote_ref: Option<TrackedRemoteRef>,
task_queue: ModelHandle<TaskQueue>,
}
#[cfg(feature = "local_fs")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TrackedRemoteRef {
full_ref_name: String,
}
#[cfg(feature = "local_fs")]
impl TrackedRemoteRef {
pub(crate) fn from_full_ref_name(full_ref_name: impl Into<String>) -> Option<Self> {
let full_ref_name = full_ref_name.into();
if !full_ref_name.starts_with("refs/remotes/") {
return None;
}
let ref_path = Path::new(&full_ref_name);
if ref_path.has_root() {
return None;
}
let mut component_count = 0;
for component in ref_path.components() {
match component {
Component::Normal(_) => component_count += 1,
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
}
}
(component_count >= 4).then_some(Self { full_ref_name })
}
fn full_ref_name(&self) -> &str {
&self.full_ref_name
}
}
impl Repository {
/// Creates a new Repository instance.
pub(super) fn new(
@@ -91,13 +128,9 @@ impl Repository {
gitignores_for_directory(&local_path)
};
let common_git_directory = external_git_directory.as_ref().and_then(|ext| {
ext.to_local_path()
.and_then(|local| Self::derive_common_git_dir(&local))
.and_then(|p| StandardizedPath::try_from_local(&p).ok())
// Only store when it differs from external_git_directory.
.filter(|common| common != ext)
});
let common_git_directory = external_git_directory
.as_ref()
.and_then(Self::derive_common_git_directory);
Self {
root_dir,
@@ -107,6 +140,8 @@ impl Repository {
next_subscriber_id: 0,
#[cfg(feature = "local_fs")]
gitignores,
#[cfg(feature = "local_fs")]
tracked_remote_ref: None,
task_queue,
}
}
@@ -123,6 +158,17 @@ impl Repository {
None
}
fn derive_common_git_directory(
external_git_directory: &StandardizedPath,
) -> Option<StandardizedPath> {
external_git_directory
.to_local_path()
.and_then(|local| Self::derive_common_git_dir(&local))
.and_then(|path| StandardizedPath::try_from_local(&path).ok())
// Only store when it differs from external_git_directory.
.filter(|common| common != external_git_directory)
}
/// The root directory of this repository.
pub fn root_dir(&self) -> &StandardizedPath {
&self.root_dir
@@ -134,6 +180,23 @@ impl Repository {
self.external_git_directory.as_ref()
}
/// Adds linked-worktree git metadata when it was not known at registration time.
///
/// Directory registrations can be created from a raw path before git detection completes.
/// Preserve any metadata already associated with the repository, since later raw-path
/// registrations must not downgrade a known linked worktree.
pub(super) fn enrich_external_git_directory(
&mut self,
external_git_directory: StandardizedPath,
) {
if self.external_git_directory.is_some() {
return;
}
self.common_git_directory = Self::derive_common_git_directory(&external_git_directory);
self.external_git_directory = Some(external_git_directory);
}
/// Returns the path to the actual `.git` directory for this repository.
///
/// For normal repositories this is `root_dir/.git`. For worktrees, the
@@ -159,6 +222,109 @@ impl Repository {
.unwrap_or_else(|| self.git_dir())
}
#[cfg(feature = "local_fs")]
pub(crate) fn tracked_remote_ref_path(&self) -> Option<PathBuf> {
self.tracked_remote_ref
.as_ref()
.map(|tracked_ref| self.common_git_dir().join(tracked_ref.full_ref_name()))
}
#[cfg(feature = "local_fs")]
pub(crate) fn tracks_remote_ref_path(&self, remote_ref_path: &Path) -> bool {
self.tracked_remote_ref_path().is_some_and(|tracked_path| {
Self::path_for_comparison(&tracked_path) == Self::path_for_comparison(remote_ref_path)
})
}
#[cfg(feature = "local_fs")]
pub(crate) fn update_tracked_remote_ref(
&mut self,
tracked_remote_ref: Option<TrackedRemoteRef>,
) -> bool {
if self.tracked_remote_ref == tracked_remote_ref {
return false;
}
self.tracked_remote_ref = tracked_remote_ref;
true
}
#[cfg(feature = "local_fs")]
fn path_for_comparison(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(feature = "local_fs")]
pub(crate) async fn resolve_tracked_remote_ref(root_dir: PathBuf) -> Option<TrackedRemoteRef> {
let output = galaxy_util::git::run_git_command(
&root_dir,
&["rev-parse", "--symbolic-full-name", "@{u}"],
)
.await
.ok()?;
let full_ref_name = output.lines().next()?.trim();
TrackedRemoteRef::from_full_ref_name(full_ref_name)
}
#[cfg(feature = "local_fs")]
pub(crate) fn refresh_tracked_remote_ref(
&mut self,
notify: bool,
ctx: &mut ModelContext<Self>,
) {
let root_dir = self.root_dir().to_local_path_lossy();
ctx.spawn(
Repository::resolve_tracked_remote_ref(root_dir),
move |repository, tracked_remote_ref, ctx| {
let tracked_remote_ref_changed =
repository.update_tracked_remote_ref(tracked_remote_ref);
if notify && tracked_remote_ref_changed {
repository.enqueue_remote_ref_update(ctx);
}
},
);
}
#[cfg(feature = "local_fs")]
fn enqueue_remote_ref_update(&mut self, ctx: &mut ModelContext<Self>) {
let repository_handle = ctx.handle();
let subscriber_ids = self.get_subscriber_ids();
let update = RepositoryUpdate {
remote_ref_updated: true,
..Default::default()
};
self.task_queue.update(ctx, |queue, ctx| {
for subscriber_id in subscriber_ids {
queue.enqueue_incremental_update(
repository_handle.clone(),
subscriber_id,
update.clone(),
ctx,
);
}
});
}
#[cfg(feature = "local_fs")]
fn watch_paths(&self) -> Vec<StandardizedPath> {
let mut paths = vec![self.root_dir.clone()];
if let Some(external_git_dir) = &self.external_git_directory {
paths.push(external_git_dir.clone());
}
if let Some(common_git_dir) = &self.common_git_directory {
if let Some(common_local) = common_git_dir.to_local_path() {
let refs_dir = common_local.join("refs");
if let Ok(refs_std) = StandardizedPath::from_local_canonicalized(&refs_dir) {
paths.push(refs_std);
}
let config_file = common_local.join("config");
if let Ok(config_std) = StandardizedPath::from_local_canonicalized(&config_file) {
paths.push(config_std);
}
}
}
paths
}
/// Returns the current watcher count.
pub fn watcher_count(&self) -> usize {
self.subscribers.len()
@@ -186,31 +352,16 @@ impl Repository {
#[cfg(feature = "local_fs")]
let registration_future: BoxFuture<'static, Result<(), RepoMetadataError>> =
if should_start_watching {
// Prepare list of directories to watch
let mut directories_to_watch = vec![self.root_dir.clone()];
let directories_to_watch = self.watch_paths();
// Reuse the gitignores we already built at construction so the
// watch descend filter doesn't re-read `.gitignore` from disk.
let gitignores = self.gitignores.clone();
// Watch the per-worktree gitdir for worktree-specific events
// (HEAD, index.lock under .git/worktrees/<name>/).
if let Some(external_git_dir) = &self.external_git_directory {
directories_to_watch.push(external_git_dir.clone());
}
// For linked worktrees, also watch .git/refs so shared ref
// changes (refs/heads/*) are visible even when the main
// worktree isn't registered.
if let Some(common_git_dir) = &self.common_git_directory {
if let Some(common_local) = common_git_dir.to_local_path() {
let refs_dir = common_local.join("refs").join("heads");
if let Ok(refs_std) = StandardizedPath::from_local_canonicalized(&refs_dir)
{
directories_to_watch.push(refs_std);
}
}
}
Box::pin(DirectoryWatcher::handle(ctx).update(ctx, |watcher, ctx| {
watcher.start_watching_directories(directories_to_watch, ctx)
}))
Box::pin(
DirectoryWatcher::handle(ctx).update(ctx, move |watcher, ctx| {
watcher.start_watching_directories(directories_to_watch, gitignores, ctx)
}),
)
} else {
Box::pin(ready(Ok(())))
};
@@ -223,6 +374,8 @@ impl Repository {
self.task_queue.update(ctx, |queue, ctx| {
queue.enqueue_scan(self_handle, subscriber_id, ctx);
});
#[cfg(feature = "local_fs")]
self.refresh_tracked_remote_ref(false, ctx);
StartWatching {
subscriber_id,
@@ -252,21 +405,8 @@ impl Repository {
#[cfg(feature = "local_fs")]
{
DirectoryWatcher::handle(ctx).update(ctx, |watcher, ctx| {
// Stop watching the working tree directory
std::mem::drop(watcher.stop_watching_directory(&self.root_dir, ctx));
// Mirror start_watching: stop per-worktree gitdir + shared refs.
if let Some(external_git_dir) = &self.external_git_directory {
std::mem::drop(watcher.stop_watching_directory(external_git_dir, ctx));
}
if let Some(common_git_dir) = &self.common_git_directory {
if let Some(common_local) = common_git_dir.to_local_path() {
let refs_dir = common_local.join("refs").join("heads");
if let Ok(refs_std) =
StandardizedPath::from_local_canonicalized(&refs_dir)
{
std::mem::drop(watcher.stop_watching_directory(&refs_std, ctx));
}
}
for path in self.watch_paths() {
std::mem::drop(watcher.stop_watching_directory(&path, ctx));
}
});
}
@@ -393,6 +533,7 @@ fn merge_repository_updates(acc: &mut RepositoryUpdate, incoming: &RepositoryUpd
acc.commit_updated |= incoming.commit_updated;
acc.index_lock_detected |= incoming.index_lock_detected;
acc.remote_ref_updated |= incoming.remote_ref_updated;
}
/// A generic debouncing layer for any RepositorySubscriber.
@@ -459,7 +600,7 @@ where
let st = state.lock().unwrap();
st.version
};
galaxyui::r#async::Timer::after(wait).await;
galaxyui_core::r#async::Timer::after(wait).await;
// If version unchanged, we're quiet; flush pending and exit loop.
let maybe_merged = {
@@ -507,3 +648,7 @@ where
}
}
}
#[cfg(test)]
#[path = "repository_tests.rs"]
mod tests;
@@ -1,6 +1,7 @@
use std::path::{Path, PathBuf};
use galaxy_core::HostId;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxy_util::remote_path::RemotePath;
use galaxy_util::standardized_path::StandardizedPath;
/// Identifies a repository across local and remote environments.
@@ -8,10 +9,13 @@ use galaxy_util::standardized_path::StandardizedPath;
pub enum RepositoryIdentifier {
/// A repository on the local filesystem, identified by its standardized path.
Local(StandardizedPath),
/// A repository on a remote server, identified by session + path.
Remote(RemoteRepositoryIdentifier),
/// A repository on a remote server, identified by host + path.
Remote(RemotePath),
}
/// Type alias preserved for backward compatibility.
pub type RemoteRepositoryIdentifier = RemotePath;
impl RepositoryIdentifier {
/// Convenience constructor for a local repository identifier.
pub fn local(path: StandardizedPath) -> Self {
@@ -41,28 +45,21 @@ impl RepositoryIdentifier {
Self::Remote(_) => None,
}
}
}
/// Identifies a repository on a remote server.
///
/// Pairs a [`HostId`] (to deduplicate across multiple SSH sessions to the
/// same host) with the server-side [`StandardizedPath`]. The path lives on
/// the remote machine and is constructed without I/O using encoding
/// information from the remote OS.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RemoteRepositoryIdentifier {
pub host_id: HostId,
pub path: StandardizedPath,
}
impl RemoteRepositoryIdentifier {
pub fn new(host_id: HostId, path: StandardizedPath) -> Self {
Self { host_id, path }
/// Converts this identifier to a `LocalOrRemotePath`.
///
/// Returns `None` only for `Local` identifiers whose `StandardizedPath`
/// cannot be converted to a local `PathBuf` (cross-platform edge case).
pub fn to_local_or_remote_path(&self) -> Option<LocalOrRemotePath> {
match self {
Self::Local(path) => path.to_local_path().map(LocalOrRemotePath::Local),
Self::Remote(remote) => Some(LocalOrRemotePath::Remote(remote.clone())),
}
}
}
impl From<RemoteRepositoryIdentifier> for RepositoryIdentifier {
fn from(id: RemoteRepositoryIdentifier) -> Self {
impl From<RemotePath> for RepositoryIdentifier {
fn from(id: RemotePath) -> Self {
Self::Remote(id)
}
}
@@ -0,0 +1,281 @@
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::time::Duration;
use futures::channel::mpsc;
use futures::{FutureExt as _, StreamExt as _};
use virtual_fs::{Stub, VirtualFS};
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui_core::r#async::Timer;
use galaxyui_core::{App, ModelContext};
use super::{merge_repository_updates, Repository, RepositorySubscriber, TrackedRemoteRef};
use crate::repositories::stub_git_repository;
use crate::watcher::DirectoryWatcher;
use crate::{RepositoryUpdate, TargetFile};
struct RecordingSubscriber {
update_tx: mpsc::UnboundedSender<RepositoryUpdate>,
}
impl RepositorySubscriber for RecordingSubscriber {
fn on_scan(
&mut self,
_repository: &Repository,
_ctx: &mut ModelContext<Repository>,
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
Box::pin(async {})
}
fn on_files_updated(
&mut self,
_repository: &Repository,
update: &RepositoryUpdate,
_ctx: &mut ModelContext<Repository>,
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
let update = update.clone();
let update_tx = self.update_tx.clone();
Box::pin(async move {
let _ = update_tx.unbounded_send(update);
})
}
}
fn add_recording_subscriber(
repository: &mut Repository,
update_tx: mpsc::UnboundedSender<RepositoryUpdate>,
) {
let subscriber_id = repository.next_subscriber_id;
repository.next_subscriber_id += 1;
repository
.subscribers
.insert(subscriber_id, Box::new(RecordingSubscriber { update_tx }));
}
#[test]
fn tracked_remote_ref_validates_full_ref_names() {
assert_eq!(
TrackedRemoteRef::from_full_ref_name("refs/remotes/origin/main")
.unwrap()
.full_ref_name(),
"refs/remotes/origin/main"
);
assert_eq!(
TrackedRemoteRef::from_full_ref_name("refs/remotes/origin/feature/nested")
.unwrap()
.full_ref_name(),
"refs/remotes/origin/feature/nested"
);
assert!(TrackedRemoteRef::from_full_ref_name("refs/heads/main").is_none());
assert!(TrackedRemoteRef::from_full_ref_name("refs/remotes/origin").is_none());
assert!(TrackedRemoteRef::from_full_ref_name("/refs/remotes/origin/main").is_none());
assert!(TrackedRemoteRef::from_full_ref_name("refs/remotes/origin/../main").is_none());
}
#[test]
fn tracked_remote_ref_path_uses_common_git_dir() {
VirtualFS::test(
"tracked_remote_ref_path_uses_common_git_dir",
|dirs, mut vfs| {
stub_git_repository(&mut vfs, "repo");
vfs.mkdir("repo/.git/refs/remotes");
vfs.mkdir("repo/.git/refs/remotes/origin");
vfs.with_files(vec![Stub::FileWithContent(
"repo/.git/refs/remotes/origin/main",
"abc123",
)]);
let repo_path = dirs.tests().join("repo");
let remote_ref_path = repo_path.join(".git/refs/remotes/origin/main");
App::test((), |mut app| async move {
let watcher_handle = app.add_model(DirectoryWatcher::new_for_testing);
let repo_handle = watcher_handle
.update(&mut app, |watcher, ctx| {
watcher.add_directory(
StandardizedPath::from_local_canonicalized(&repo_path).unwrap(),
ctx,
)
})
.unwrap();
repo_handle.update(&mut app, |repo, _| {
assert!(
repo.update_tracked_remote_ref(TrackedRemoteRef::from_full_ref_name(
"refs/remotes/origin/main"
))
);
assert_eq!(
repo.tracked_remote_ref_path(),
Some(remote_ref_path.clone())
);
assert!(repo.tracks_remote_ref_path(&remote_ref_path));
});
});
},
);
}
#[test]
fn tracked_remote_ref_path_uses_linked_worktree_common_git_dir() {
VirtualFS::test(
"tracked_remote_ref_path_uses_linked_worktree_common_git_dir",
|dirs, mut vfs| {
stub_git_repository(&mut vfs, "repo");
vfs.mkdir("repo/.git/worktrees");
vfs.mkdir("repo/.git/worktrees/wt");
vfs.mkdir("repo/.git/refs/remotes");
vfs.mkdir("repo/.git/refs/remotes/origin");
vfs.mkdir("wt");
vfs.with_files(vec![
Stub::FileWithContent("repo/.git/worktrees/wt/HEAD", "ref: refs/heads/feature"),
Stub::FileWithContent("repo/.git/refs/remotes/origin/feature", "abc123"),
]);
let worktree_path = dirs.tests().join("wt");
let external_git_dir = dirs.tests().join("repo/.git/worktrees/wt");
let remote_ref_path = dirs.tests().join("repo/.git/refs/remotes/origin/feature");
App::test((), |mut app| async move {
let watcher_handle = app.add_model(DirectoryWatcher::new_for_testing);
let repo_handle = watcher_handle
.update(&mut app, |watcher, ctx| {
watcher.add_directory_with_git_dir(
StandardizedPath::from_local_canonicalized(&worktree_path).unwrap(),
Some(
StandardizedPath::from_local_canonicalized(&external_git_dir)
.unwrap(),
),
ctx,
)
})
.unwrap();
repo_handle.update(&mut app, |repo, _| {
assert!(
repo.update_tracked_remote_ref(TrackedRemoteRef::from_full_ref_name(
"refs/remotes/origin/feature"
))
);
assert_eq!(
repo.tracked_remote_ref_path(),
Some(remote_ref_path.clone())
);
assert!(repo.tracks_remote_ref_path(&remote_ref_path));
});
});
},
);
}
#[test]
fn merge_repository_updates_preserves_remote_ref_updates() {
let mut acc = RepositoryUpdate {
added: [TargetFile::new(PathBuf::from("/repo/file.txt"), false)].into(),
..Default::default()
};
let incoming = RepositoryUpdate {
remote_ref_updated: true,
..Default::default()
};
merge_repository_updates(&mut acc, &incoming);
assert!(acc.remote_ref_updated);
assert!(acc
.added
.contains(&TargetFile::new(PathBuf::from("/repo/file.txt"), false)));
}
#[test]
fn tracked_remote_ref_change_notifies_subscribers() {
VirtualFS::test("tracked_remote_ref_change_notifies", |dirs, mut vfs| {
stub_git_repository(&mut vfs, "repo");
let repo_path = dirs.tests().join("repo");
App::test((), |mut app| async move {
let watcher_handle = app.add_singleton_model(DirectoryWatcher::new_for_testing);
let repo_handle = watcher_handle
.update(&mut app, |watcher, ctx| {
watcher.add_directory(
StandardizedPath::from_local_canonicalized(&repo_path).unwrap(),
ctx,
)
})
.unwrap();
let (update_tx, mut update_rx) = mpsc::unbounded::<RepositoryUpdate>();
repo_handle.update(&mut app, |repo, _| {
add_recording_subscriber(repo, update_tx);
});
repo_handle.update(&mut app, |repo, ctx| {
if repo.update_tracked_remote_ref(TrackedRemoteRef::from_full_ref_name(
"refs/remotes/origin/main",
)) {
repo.enqueue_remote_ref_update(ctx);
}
});
let update = update_rx.next().await.expect("remote ref update");
assert!(update.remote_ref_updated);
assert!(!update.commit_updated);
assert!(!update.index_lock_detected);
assert!(update.added.is_empty());
assert!(update.modified.is_empty());
assert!(update.deleted.is_empty());
assert!(update.moved.is_empty());
});
});
}
#[test]
fn unchanged_tracked_remote_ref_does_not_notify_subscribers() {
VirtualFS::test(
"unchanged_tracked_remote_ref_does_not_notify",
|dirs, mut vfs| {
stub_git_repository(&mut vfs, "repo");
let repo_path = dirs.tests().join("repo");
App::test((), |mut app| async move {
let watcher_handle = app.add_singleton_model(DirectoryWatcher::new_for_testing);
let repo_handle = watcher_handle
.update(&mut app, |watcher, ctx| {
watcher.add_directory(
StandardizedPath::from_local_canonicalized(&repo_path).unwrap(),
ctx,
)
})
.unwrap();
let (update_tx, mut update_rx) = mpsc::unbounded::<RepositoryUpdate>();
repo_handle.update(&mut app, |repo, _| {
add_recording_subscriber(repo, update_tx);
});
repo_handle.update(&mut app, |repo, _| {
repo.update_tracked_remote_ref(TrackedRemoteRef::from_full_ref_name(
"refs/remotes/origin/main",
));
});
repo_handle.update(&mut app, |repo, ctx| {
if repo.update_tracked_remote_ref(TrackedRemoteRef::from_full_ref_name(
"refs/remotes/origin/main",
)) {
repo.enqueue_remote_ref_update(ctx);
}
});
futures::select! {
update = update_rx.next().fuse() => {
panic!("unexpected remote ref update: {update:?}");
}
_ = futures::FutureExt::fuse(Timer::after(Duration::from_millis(100))) => {}
}
});
},
);
}
@@ -0,0 +1,257 @@
//! Standing repository queries maintained alongside the canonical file tree.
//!
//! These results contain project-derived context paths that must remain available
//! even when the visible file tree is intentionally lazy or shallow.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use galaxy_util::standardized_path::StandardizedPath;
/// Repository-scoped standing query configuration.
#[derive(Debug, Clone)]
pub struct StandingQueryDefinitions {
project_skill_provider_paths: Vec<PathBuf>,
project_rule_file_names: Vec<String>,
}
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()],
}
}
}
impl StandingQueryDefinitions {
pub fn set_project_skill_provider_paths(&mut self, paths: impl IntoIterator<Item = PathBuf>) {
self.project_skill_provider_paths = paths.into_iter().collect();
}
pub fn project_skill_provider_paths(&self) -> &[PathBuf] {
&self.project_skill_provider_paths
}
fn is_project_skill_provider_directory(&self, path: &Path) -> bool {
self.project_skill_provider_paths
.iter()
.any(|provider_path| path.ends_with(provider_path))
}
fn project_skill_provider_ancestor<'a>(&self, path: &'a Path) -> Option<&'a Path> {
path.ancestors()
.find(|ancestor| self.is_project_skill_provider_directory(ancestor))
}
fn is_direct_project_skill_provider_child(&self, path: &Path) -> bool {
path.parent()
.is_some_and(|parent| self.is_project_skill_provider_directory(parent))
}
fn is_project_skill_file(&self, path: &Path) -> bool {
path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md")
&& path
.parent()
.and_then(Path::parent)
.is_some_and(|skills_root| self.is_project_skill_provider_directory(skills_root))
}
fn is_project_rule_file(&self, path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|file_name| {
self.project_rule_file_names
.iter()
.any(|rule_name| file_name.eq_ignore_ascii_case(rule_name))
})
}
}
/// A path retained by a standing query.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct StandingQueryContent {
pub path: StandardizedPath,
pub is_directory: bool,
}
impl StandingQueryContent {
pub fn file(path: StandardizedPath) -> Self {
Self {
path,
is_directory: false,
}
}
pub fn directory(path: StandardizedPath) -> Self {
Self {
path,
is_directory: true,
}
}
}
/// Current paths matching each standing repository query.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StandingQueryResults {
project_skills: HashSet<StandingQueryContent>,
project_rules: HashSet<StandingQueryContent>,
}
impl StandingQueryResults {
pub fn project_skills(&self) -> impl Iterator<Item = &StandingQueryContent> {
self.project_skills.iter()
}
pub fn project_rules(&self) -> impl Iterator<Item = &StandingQueryContent> {
self.project_rules.iter()
}
/// Records a path encountered while traversing the repository.
pub(crate) fn record_path(
&mut self,
path: &Path,
is_directory: bool,
definitions: &StandingQueryDefinitions,
) {
let standardized = StandardizedPath::from_local_absolute_unchecked(path);
if is_directory && definitions.is_project_skill_provider_directory(path) {
self.project_skills
.insert(StandingQueryContent::directory(standardized.clone()));
}
if !is_directory && definitions.is_project_skill_file(path) {
self.project_skills
.insert(StandingQueryContent::file(standardized.clone()));
}
if !is_directory && definitions.is_project_rule_file(path) {
self.project_rules
.insert(StandingQueryContent::file(standardized));
}
}
pub(crate) fn record_direct_project_skill_provider_child_change(
&mut self,
path: &Path,
definitions: &StandingQueryDefinitions,
) {
if definitions.is_direct_project_skill_provider_child(path) {
if let Some(provider_root) = definitions.project_skill_provider_ancestor(path) {
self.project_skills.insert(StandingQueryContent::directory(
StandardizedPath::from_local_absolute_unchecked(provider_root),
));
}
}
}
/// Records an eligible project skill reached through a directory symlink during standing
/// query evaluation. The lexical path is intentionally retained so consumers address the
/// skill through the provider entry rather than the symlink target.
pub(crate) fn record_followed_project_skill_directory(
&mut self,
path: &Path,
definitions: &StandingQueryDefinitions,
) {
if !definitions.is_direct_project_skill_provider_child(path) {
return;
}
let skill_file = path.join("SKILL.md");
if skill_file.is_file() {
self.record_path(&skill_file, false, definitions);
}
}
pub fn insert_project_skill(&mut self, content: StandingQueryContent) {
self.project_skills.insert(content);
}
pub fn insert_project_rule(&mut self, content: StandingQueryContent) {
self.project_rules.insert(content);
}
pub fn apply_delta(&mut self, delta: &StandingQueryResultsDelta) {
for removed in &delta.removed_project_skills {
self.project_skills.remove(removed);
}
for removed in &delta.removed_project_rules {
self.project_rules.remove(removed);
}
self.project_skills
.extend(delta.upserted_project_skills.iter().cloned());
self.project_rules
.extend(delta.upserted_project_rules.iter().cloned());
}
/// Replaces results beneath changed roots and returns the observable delta.
///
/// Upserts are emitted even when a matching path already exists so consumers
/// reread modified skill and rules file contents.
pub fn replace_subtrees(
&mut self,
removed_roots: &[StandardizedPath],
discovered: StandingQueryResults,
) -> StandingQueryResultsDelta {
let mut delta = StandingQueryResultsDelta::default();
for root in removed_roots {
let removed_skills = self
.project_skills
.iter()
.filter(|content| content.path.starts_with(root))
.cloned()
.collect::<Vec<_>>();
let removed_rules = self
.project_rules
.iter()
.filter(|content| content.path.starts_with(root))
.cloned()
.collect::<Vec<_>>();
delta.removed_project_skills.extend(removed_skills);
delta.removed_project_rules.extend(removed_rules);
}
delta
.upserted_project_skills
.extend(discovered.project_skills);
delta
.upserted_project_rules
.extend(discovered.project_rules);
self.apply_delta(&delta);
delta
}
pub fn as_snapshot_delta(&self) -> StandingQueryResultsDelta {
StandingQueryResultsDelta {
upserted_project_skills: self.project_skills.iter().cloned().collect(),
removed_project_skills: Vec::new(),
upserted_project_rules: self.project_rules.iter().cloned().collect(),
removed_project_rules: Vec::new(),
}
}
}
/// Changes to standing query results for one repository.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StandingQueryResultsDelta {
pub upserted_project_skills: Vec<StandingQueryContent>,
pub removed_project_skills: Vec<StandingQueryContent>,
pub upserted_project_rules: Vec<StandingQueryContent>,
pub removed_project_rules: Vec<StandingQueryContent>,
}
impl StandingQueryResultsDelta {
pub fn is_empty(&self) -> bool {
self.upserted_project_skills.is_empty()
&& self.removed_project_skills.is_empty()
&& self.upserted_project_rules.is_empty()
&& self.removed_project_rules.is_empty()
}
pub fn project_skills_changed(&self) -> bool {
!self.upserted_project_skills.is_empty() || !self.removed_project_skills.is_empty()
}
pub fn project_rules_changed(&self) -> bool {
!self.upserted_project_rules.is_empty() || !self.removed_project_rules.is_empty()
}
}
#[cfg(test)]
#[path = "standing_queries_tests.rs"]
mod tests;
@@ -0,0 +1,128 @@
use super::*;
fn repo_path(path: &str) -> PathBuf {
std::env::temp_dir()
.join("repo_metadata_standing_queries_tests")
.join(path)
}
fn standardized(path: &Path) -> StandardizedPath {
StandardizedPath::try_from_local(path).unwrap()
}
fn definitions() -> StandingQueryDefinitions {
let mut definitions = StandingQueryDefinitions::default();
definitions.set_project_skill_provider_paths([PathBuf::from(".agents/skills")]);
definitions
}
#[test]
fn records_provider_skill_files_and_project_rules() {
let definitions = definitions();
let mut results = StandingQueryResults::default();
let skills_provider = repo_path(".agents/skills");
let skill_file = repo_path(".agents/skills/review/SKILL.md");
let root_rule = repo_path("WARP.md");
let nested_rule = repo_path("packages/api/AGENTS.md");
results.record_path(&skills_provider, true, &definitions);
results.record_path(&skill_file, false, &definitions);
results.record_path(&root_rule, false, &definitions);
results.record_path(&nested_rule, false, &definitions);
assert!(
results
.project_skills()
.any(|content| content
== &StandingQueryContent::directory(standardized(&skills_provider)))
);
assert!(results
.project_skills()
.any(|content| { content == &StandingQueryContent::file(standardized(&skill_file)) }));
assert!(results
.project_rules()
.any(|content| content == &StandingQueryContent::file(standardized(&root_rule))));
assert!(results
.project_rules()
.any(|content| { content == &StandingQueryContent::file(standardized(&nested_rule)) }));
}
#[test]
fn replacing_removed_direct_skill_child_can_reupsert_provider_for_hydration() {
let definitions = definitions();
let provider_path = repo_path(".agents/skills");
let skill_path = repo_path(".agents/skills/review/SKILL.md");
let removed_skill_dir = repo_path(".agents/skills/review");
let provider = StandingQueryContent::directory(standardized(&provider_path));
let skill = StandingQueryContent::file(standardized(&skill_path));
let mut results = StandingQueryResults::default();
results.insert_project_skill(provider.clone());
results.insert_project_skill(skill.clone());
let mut discovered = StandingQueryResults::default();
discovered.record_direct_project_skill_provider_child_change(&removed_skill_dir, &definitions);
let delta = results.replace_subtrees(&[standardized(&removed_skill_dir)], discovered);
assert_eq!(delta.removed_project_skills, vec![skill]);
assert_eq!(delta.upserted_project_skills, vec![provider.clone()]);
assert!(results.project_skills().any(|content| content == &provider));
assert!(!results
.project_skills()
.any(|content| content.path == standardized(&skill_path)));
}
#[test]
fn support_file_beneath_skill_does_not_synthesize_provider_update() {
let definitions = definitions();
let mut results = StandingQueryResults::default();
let support_file = repo_path(".agents/skills/review/README.md");
results.record_path(&support_file, false, &definitions);
assert!(results.project_skills().next().is_none());
}
/// Emulates the open `AGENTS.md` discovery contract that non-Warp agents follow:
/// walk from a working directory up to the repository root, collecting any
/// `AGENTS.md` rule files via the same predicate Warp uses to index project
/// rules. Guards the `WARP.md` → `AGENTS.md` rename so the repo-root agent
/// context file stays present, non-empty, and discoverable.
#[test]
fn repo_root_agents_md_is_discovered_by_rule_file_contract() {
let definitions = StandingQueryDefinitions::default();
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// Locate the repository root (the ancestor that holds `.git`).
let repo_root = manifest_dir
.ancestors()
.find(|ancestor| ancestor.join(".git").exists())
.expect("repo_metadata crate should live inside the warp git checkout");
// Walk from the crate dir up to (and including) the repo root, collecting
// every `AGENTS.md` the rule-file predicate recognizes — the same
// nearest-file-up-the-tree contract a conformant non-Warp agent uses.
let discovered: Vec<PathBuf> = manifest_dir
.ancestors()
.take_while(|ancestor| ancestor.starts_with(repo_root))
.map(|ancestor| ancestor.join("AGENTS.md"))
.filter(|candidate| candidate.is_file() && definitions.is_project_rule_file(candidate))
.collect();
let root_agents_md = repo_root.join("AGENTS.md");
assert!(
discovered.contains(&root_agents_md),
"repo-root AGENTS.md should be discovered by the rule-file contract; found {discovered:?}"
);
let contents =
std::fs::read_to_string(&root_agents_md).expect("repo-root AGENTS.md should be readable");
assert!(
!contents.trim().is_empty(),
"repo-root AGENTS.md should not be empty"
);
// Clean rename: the repo no longer ships a root WARP.md.
assert!(
!repo_root.join("WARP.md").exists(),
"repo-root WARP.md should have been renamed to AGENTS.md"
);
}
+2 -4
View File
@@ -1,9 +1,7 @@
use galaxy_core::{
register_telemetry_event,
telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc},
};
use serde_json::{json, Value};
use strum_macros::{EnumDiscriminants, EnumIter};
use galaxy_core::register_telemetry_event;
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
#[derive(Clone, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
+222 -133
View File
@@ -1,25 +1,25 @@
use std::{
collections::{hash_map::Entry, HashMap, HashSet, VecDeque},
future::Future,
hash::{Hash, Hasher},
path::{Path, PathBuf},
pin::Pin,
};
use std::collections::{HashMap, HashSet, VecDeque};
use std::future::Future;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::pin::Pin;
#[cfg(feature = "local_fs")]
use futures::{future::OptionFuture, FutureExt as _};
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle};
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui_core::{Entity, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle};
use crate::{repository::SubscriberId, RepoMetadataError, Repository};
use crate::repository::SubscriberId;
use crate::{RepoMetadataError, Repository};
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
use ignore::gitignore::Gitignore;
use watcher::{BulkFilesystemWatcher, BulkFilesystemWatcherEvent};
use crate::entry::{
extract_worktree_git_dir, is_commit_related_git_file, is_git_internal_path,
is_index_lock_file, is_shared_git_ref,
is_common_git_config, is_index_lock_file, is_remote_tracking_ref,
is_shared_git_ref, is_tracking_state_git_file,
};
/// Duration between filesystem watch events in milliseconds
const FILESYSTEM_WATCHER_DEBOUNCE_MILLI_SECS: u64 = 500;
@@ -41,6 +41,12 @@ pub struct DirectoryWatcher {
/// Handle to the internal processing queue model that orders scan & update tasks.
processing_queue: ModelHandle<TaskQueue>,
/// Paths that must be watched (and indexed) even when they are gitignored
/// or beyond the tree's size limit — e.g. skill provider directories that
/// consumers (LSP, MCP) need live updates for.
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
force_included_paths: Vec<PathBuf>,
}
impl DirectoryWatcher {
@@ -68,6 +74,7 @@ impl DirectoryWatcher {
#[cfg(feature = "local_fs")]
watcher: Some(fs_watcher),
processing_queue,
force_included_paths: Vec::new(),
}
}
@@ -92,6 +99,20 @@ impl DirectoryWatcher {
#[cfg(feature = "local_fs")]
watcher: Some(fs_watcher),
processing_queue,
force_included_paths: Vec::new(),
}
}
/// Registers paths that must be watched even when gitignored. Mirrors
/// `LocalRepoMetadataModel::register_force_included_paths` but applies to
/// the watcher backing `Repository` subscribers (LSP, MCP). Must be called
/// before repositories begin watching to take effect on already-registered
/// watches.
pub fn register_force_included_paths(&mut self, paths: impl IntoIterator<Item = PathBuf>) {
for path in paths {
if !self.force_included_paths.contains(&path) {
self.force_included_paths.push(path);
}
}
}
@@ -121,15 +142,18 @@ impl DirectoryWatcher {
self.directories.contains_key(path)
}
/// Find repositories affected by a git directory change using three-tier
/// Find repositories affected by a git directory change using scope-aware
/// scope-aware routing:
///
/// 1. **Worktree-specific** (`.git/worktrees/<name>/…`): only the repo
/// whose `external_git_directory` matches the extracted worktree gitdir.
/// 2. **Shared refs** (`.git/refs/heads/*`): all repos whose
/// 2. **Remote refs** (`.git/refs/remotes/*`): repos whose cached tracked
/// upstream ref resolves to the changed loose remote ref.
/// 3. **Shared refs** (`.git/refs/heads/*`): all repos whose
/// `common_git_dir()` is a prefix of the event path (main repo +
/// all linked worktrees).
/// 3. **Repo-specific** (`.git/HEAD`, `.git/index.lock`, etc.): only the
/// 4. **Common config** (`.git/config`): all repos sharing that common Git directory.
/// 5. **Repo-specific** (`.git/HEAD`, `.git/index.lock`, etc.): only the
/// repo whose working tree directly contains `.git` (main repo).
#[cfg(feature = "local_fs")]
fn find_repos_for_git_event(
@@ -153,8 +177,20 @@ impl DirectoryWatcher {
}
}
}
} else if is_remote_tracking_ref(git_path) {
log::debug!(
"[GIT_EVENT_ROUTING] tier=remote-ref path={}",
git_path.display()
);
for repo_handle in self.directories.values() {
if repo_handle.read(ctx, |repo, _| repo.tracks_remote_ref_path(git_path))
&& !affected.iter().any(|r| r == repo_handle)
{
affected.push(repo_handle.clone());
}
}
} else if is_shared_git_ref(git_path) {
// Tier 2: shared ref — broadcast to every repo whose
// Tier 3: shared ref — broadcast to every repo whose
// common_git_dir() is a prefix of the event path.
log::debug!(
"[GIT_EVENT_ROUTING] tier=shared-ref path={}",
@@ -178,8 +214,22 @@ impl DirectoryWatcher {
}
}
}
} else if is_common_git_config(git_path) {
log::debug!(
"[GIT_EVENT_ROUTING] tier=common-config path={}",
git_path.display()
);
let Some(common_git_dir) = git_path.parent() else {
return affected;
};
for repo_handle in self.directories.values() {
let common = repo_handle.read(ctx, |repo, _| repo.common_git_dir());
if common == common_git_dir && !affected.iter().any(|r| r == repo_handle) {
affected.push(repo_handle.clone());
}
}
} else {
// Tier 3: repo-specific (.git/HEAD, .git/index.lock) — only the
// Tier 5: repo-specific (.git/HEAD, .git/index.lock) — only the
// repo whose root_dir directly contains .git.
log::debug!(
"[GIT_EVENT_ROUTING] tier=repo-specific path={}",
@@ -237,14 +287,21 @@ impl DirectoryWatcher {
));
}
// Check if there's an existing registration to reuse.
let entry = self.directories.entry(repository_path);
if let Entry::Occupied(ref entry) = entry {
// Check if there's an existing registration to reuse. A raw-path registration can happen
// before git detection completes, so enrich the existing handle when a later registration
// identifies it as a linked worktree.
if let Some(repository_handle) = self.directories.get(&repository_path).cloned() {
log::debug!("Using already-registered repository");
return Ok(entry.get().clone());
if let Some(external_git_directory) = external_git_directory {
repository_handle.update(ctx, |repository, _ctx| {
repository.enrich_external_git_directory(external_git_directory)
});
}
return Ok(repository_handle);
}
// The repository is either not registered, or has expired.
let entry = self.directories.entry(repository_path);
let queue_handle = self.processing_queue.clone();
let repository_handle = ctx.add_model(|_ctx| {
Repository::new(
@@ -265,11 +322,12 @@ impl DirectoryWatcher {
pub(crate) fn start_watching_directories(
&mut self,
directory_paths: Vec<StandardizedPath>,
gitignores: Vec<Gitignore>,
ctx: &mut ModelContext<Self>,
) -> impl Future<Output = Result<(), RepoMetadataError>> {
let futures: Vec<_> = directory_paths
.into_iter()
.map(|path| self.start_watching_directory(&path, ctx))
.map(|path| self.start_watching_directory(&path, gitignores.clone(), ctx))
.collect();
async move {
@@ -287,21 +345,27 @@ impl DirectoryWatcher {
pub(crate) fn start_watching_directory(
&mut self,
directory_path: &StandardizedPath,
gitignores: Vec<Gitignore>,
ctx: &mut ModelContext<Self>,
) -> impl Future<Output = Result<(), RepoMetadataError>> {
let local_path = directory_path.to_local_path();
let registration_future = if let Some(ref watcher) = self.watcher {
if let Some(local_path) = local_path.clone() {
// `gitignores` are the repo's cached root + global gitignores,
// threaded in from `Repository::start_watching` so we neither
// re-read `.gitignore` from disk nor re-enter the (already
// borrowed) `Repository` model here.
let force_included_paths = self.force_included_paths.clone();
watcher.update(ctx, |watcher, _ctx| {
use crate::entry::should_ignore_git_path;
use notify_debouncer_full::notify::{RecursiveMode, WatchFilter};
use std::sync::Arc;
use notify_debouncer_full::notify::RecursiveMode;
let watch_filter = WatchFilter::with_filter(Arc::new(move |watch_path| {
!should_ignore_git_path(watch_path)
}));
use crate::entry::repo_watch_filter;
Some(watcher.register_path(&local_path, watch_filter, RecursiveMode::Recursive))
Some(watcher.register_path(
&local_path,
repo_watch_filter(gitignores, force_included_paths),
RecursiveMode::Recursive,
))
})
} else {
log::warn!("Cannot watch non-local path: {directory_path}");
@@ -369,7 +433,12 @@ impl DirectoryWatcher {
}
/// Handles events from the internal task queue.
fn handle_queue_event(&mut self, event: &TaskQueueEvent, ctx: &mut ModelContext<Self>) {
fn handle_queue_event(
&mut self,
_: ModelHandle<TaskQueue>,
event: &TaskQueueEvent,
ctx: &mut ModelContext<Self>,
) {
let &TaskQueueEvent::TaskEnqueued = event;
self.processing_queue.update(ctx, |queue, ctx| {
queue.advance(ctx);
@@ -377,113 +446,127 @@ impl DirectoryWatcher {
}
#[cfg(feature = "local_fs")]
fn find_existing_subpath(path: &PathBuf) -> Option<PathBuf> {
// Attempt to find a subdirectory that exists in the filesystem.
let mut current = path.to_owned();
while !current.as_path().exists() {
if !current.pop() {
return None;
fn record_git_internal_path_update(
&self,
path: &Path,
repo_updates: &mut HashMap<ModelHandle<Repository>, RepositoryUpdate>,
repos_to_refresh_tracked_remote_ref: &mut HashSet<ModelHandle<Repository>>,
ctx: &ModelContext<Self>,
) {
let affected = self.find_repos_for_git_event(path, ctx);
let is_commit = is_commit_related_git_file(path);
let is_lock = is_index_lock_file(path);
let is_remote_ref = is_remote_tracking_ref(path);
let is_tracking_state = is_tracking_state_git_file(path);
for repo_handle in &affected {
if is_commit || is_lock || is_remote_ref {
let repo_update = repo_updates.entry(repo_handle.clone()).or_default();
if is_commit {
repo_update.commit_updated = true;
}
if is_lock {
repo_update.index_lock_detected = true;
}
if is_remote_ref {
repo_update.remote_ref_updated = true;
}
}
if is_tracking_state {
repos_to_refresh_tracked_remote_ref.insert(repo_handle.clone());
}
}
Some(current)
if !affected.is_empty() {
log::debug!(
"[GIT_EVENT_ROUTING] dispatched path={} commit_updated={is_commit} remote_ref_updated={is_remote_ref} index_lock={is_lock} tracking_state={is_tracking_state} to {} repo(s)",
path.display(),
affected.len()
);
}
}
/// Handles filesystem watcher events.
#[cfg(feature = "local_fs")]
fn handle_watcher_event(
&mut self,
_: ModelHandle<BulkFilesystemWatcher>,
event: &BulkFilesystemWatcherEvent,
ctx: &mut ModelContext<Self>,
) {
// Group changes by repository
let mut repo_updates: HashMap<ModelHandle<Repository>, RepositoryUpdate> = HashMap::new();
let mut repos_to_refresh_tracked_remote_ref: HashSet<ModelHandle<Repository>> =
HashSet::new();
let mut process_upsert_paths = |paths: &HashSet<PathBuf>,
insert: &mut dyn FnMut(
&mut RepositoryUpdate,
TargetFile,
)| {
for path in paths {
// Check if this is a .git/ internal event (e.g. HEAD, index, refs update).
if is_git_internal_path(path) {
let affected = self.find_repos_for_git_event(path, ctx);
for repo_handle in &affected {
let repo_update = repo_updates.entry(repo_handle.clone()).or_default();
if is_commit_related_git_file(path) {
repo_update.commit_updated = true;
}
if is_index_lock_file(path) {
repo_update.index_lock_detected = true;
}
}
if !affected.is_empty() {
let is_commit = is_commit_related_git_file(path);
let is_lock = is_index_lock_file(path);
log::debug!(
"[GIT_EVENT_ROUTING] dispatched path={} commit_updated={is_commit} index_lock={is_lock} to {} repo(s)",
path.display(),
affected.len()
{
let mut process_upsert_paths =
|paths: &HashSet<PathBuf>,
insert: &mut dyn FnMut(&mut RepositoryUpdate, TargetFile)| {
for path in paths {
// Check if this is a .git/ internal event (e.g. HEAD, index, refs update).
if is_git_internal_path(path) {
self.record_git_internal_path_update(
path,
&mut repo_updates,
&mut repos_to_refresh_tracked_remote_ref,
ctx,
);
continue;
}
// Attribute non-git files by their absolute path, not a canonicalized
// one: canonicalizing would follow a below-root symlink (e.g. a
// gitignored `node_modules` entry) into the symlink target's repo and
// misattribute the event. `from_local_absolute_unchecked` is safe here
// because watcher event paths are always absolute.
let standardized =
StandardizedPath::from_local_absolute_unchecked(path.as_path());
if let Some(repo_handle) = self.find_containing_directory(&standardized) {
let is_ignored =
repo_handle.read(ctx, |repo, _| repo.check_gitignore_status(path));
let target_file = TargetFile::new(path.to_path_buf(), is_ignored);
let repo_update = repo_updates.entry(repo_handle).or_default();
insert(repo_update, target_file);
}
}
continue;
};
// Process added files
process_upsert_paths(&event.added, &mut |repo_update, target_file| {
repo_update.added.insert(target_file);
});
// Process modified files
process_upsert_paths(&event.modified, &mut |repo_update, target_file| {
if !repo_update.added.contains(&target_file) {
repo_update.modified.insert(target_file);
}
// For non-git files, use standard path lookup
if let Ok(standardized) = StandardizedPath::from_local_canonicalized(path.as_path())
{
if let Some(repo_handle) = self.find_containing_directory(&standardized) {
let is_ignored =
repo_handle.read(ctx, |repo, _| repo.check_gitignore_status(path));
let target_file = TargetFile::new(path.to_path_buf(), is_ignored);
let repo_update = repo_updates.entry(repo_handle).or_default();
insert(repo_update, target_file);
}
}
}
};
// Process added files
process_upsert_paths(&event.added, &mut |repo_update, target_file| {
repo_update.added.insert(target_file);
});
// Process modified files
process_upsert_paths(&event.modified, &mut |repo_update, target_file| {
if !repo_update.added.contains(&target_file) {
repo_update.modified.insert(target_file);
}
});
});
}
// Process deleted files
for path in &event.deleted {
// Check if this is a .git/ internal event.
if is_git_internal_path(path) {
let affected = self.find_repos_for_git_event(path, ctx);
for repo_handle in affected {
let repo_update = repo_updates.entry(repo_handle).or_default();
if is_commit_related_git_file(path) {
repo_update.commit_updated = true;
}
if is_index_lock_file(path) {
repo_update.index_lock_detected = true;
}
}
self.record_git_internal_path_update(
path,
&mut repo_updates,
&mut repos_to_refresh_tracked_remote_ref,
ctx,
);
} else {
// Because this file will no longer exist, which will fail canonicalization.
// We will just try the directory path instead, which hopefully still exists.
if let Some(existing_subpath) = Self::find_existing_subpath(path) {
if let Ok(standardized) =
StandardizedPath::from_local_canonicalized(existing_subpath.as_path())
{
if let Some(repo_handle) = self.find_containing_directory(&standardized) {
// Gitignore checking is pattern-based and doesn't require file existence
let is_ignored =
repo_handle.read(ctx, |repo, _| repo.check_gitignore_status(path));
let target_file = TargetFile::new(path.to_path_buf(), is_ignored);
let repo_update = repo_updates.entry(repo_handle).or_default();
repo_update.deleted.insert(target_file);
}
}
// Attribute by the absolute (non-canonicalized) path. Deleted files can't be
// canonicalized anyway, and resolving symlinks would route the event to the
// symlink target's repo rather than the repo the path lexically belongs to.
let standardized = StandardizedPath::from_local_absolute_unchecked(path.as_path());
if let Some(repo_handle) = self.find_containing_directory(&standardized) {
// Gitignore checking is pattern-based and doesn't require file existence.
let is_ignored =
repo_handle.read(ctx, |repo, _| repo.check_gitignore_status(path));
let target_file = TargetFile::new(path.to_path_buf(), is_ignored);
let repo_update = repo_updates.entry(repo_handle).or_default();
repo_update.deleted.insert(target_file);
}
}
}
@@ -492,28 +575,25 @@ impl DirectoryWatcher {
for (to_path, from_path) in &event.moved {
// Check if this is a .git/ internal event.
if is_git_internal_path(to_path) || is_git_internal_path(from_path) {
// Merge affected repos from both paths
let mut affected = self.find_repos_for_git_event(to_path, ctx);
for repo in self.find_repos_for_git_event(from_path, ctx) {
if !affected.iter().any(|r| r == &repo) {
affected.push(repo);
}
if is_git_internal_path(to_path) {
self.record_git_internal_path_update(
to_path,
&mut repo_updates,
&mut repos_to_refresh_tracked_remote_ref,
ctx,
);
}
let paths = [to_path.as_path(), from_path.as_path()];
for repo_handle in affected {
let repo_update = repo_updates.entry(repo_handle).or_default();
for p in &paths {
if is_commit_related_git_file(p) {
repo_update.commit_updated = true;
}
if is_index_lock_file(p) {
repo_update.index_lock_detected = true;
}
}
if is_git_internal_path(from_path) {
self.record_git_internal_path_update(
from_path,
&mut repo_updates,
&mut repos_to_refresh_tracked_remote_ref,
ctx,
);
}
} else if let Ok(standardized) =
StandardizedPath::from_local_canonicalized(to_path.as_path())
{
} else {
let standardized =
StandardizedPath::from_local_absolute_unchecked(to_path.as_path());
if let Some(repo_handle) = self.find_containing_directory(&standardized) {
let to_is_ignored =
repo_handle.read(ctx, |repo, _| repo.check_gitignore_status(to_path));
@@ -540,6 +620,11 @@ impl DirectoryWatcher {
}
}
});
for repo_handle in repos_to_refresh_tracked_remote_ref {
repo_handle.update(ctx, |repo, ctx| {
repo.refresh_tracked_remote_ref(true, ctx);
});
}
}
}
@@ -611,6 +696,9 @@ pub struct RepositoryUpdate {
/// Whether the git index lock file was created or removed (`.git/index.lock`).
pub index_lock_detected: bool,
/// Whether the tracked upstream ref changed or the current tracked remote ref was updated.
pub remote_ref_updated: bool,
}
impl RepositoryUpdate {
@@ -622,6 +710,7 @@ impl RepositoryUpdate {
&& self.moved.is_empty()
&& !self.commit_updated
&& !self.index_lock_detected
&& !self.remote_ref_updated
}
/// Iterator over all created and modified files.
+237 -27
View File
@@ -5,16 +5,17 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use futures::channel::mpsc;
use futures::{FutureExt as _, StreamExt as _};
use virtual_fs::{Stub, VirtualFS};
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui_core::r#async::Timer;
use galaxyui_core::{App, ModelContext, ModelHandle};
use crate::repositories::stub_git_repository;
use crate::repository::RepositorySubscriber;
use crate::repository::{RepositorySubscriber, TrackedRemoteRef};
use crate::watcher::{DirectoryWatcher, TaskQueue};
use crate::{CanonicalizedPath, RepoMetadataError, Repository, RepositoryUpdate};
use futures::channel::mpsc;
use futures::StreamExt as _;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::r#async::Timer;
use galaxyui::{App, ModelContext, ModelHandle};
use virtual_fs::{Stub, VirtualFS};
#[test]
fn test_add_repository_success() {
@@ -46,6 +47,63 @@ fn test_add_repository_success() {
});
}
#[test]
fn test_existing_directory_registration_is_enriched_with_external_git_directory() {
VirtualFS::test(
"enrich_existing_directory_with_external_git_directory",
|dirs, mut vfs| {
vfs.mkdir("repo/.git/worktrees/worktree").mkdir("worktree");
let worktree_path = dirs.tests().join("worktree");
let external_git_dir = dirs.tests().join("repo/.git/worktrees/worktree");
let common_git_dir = dirs.tests().join("repo/.git");
App::test((), |mut app| async move {
let watcher_handle = app.add_model(DirectoryWatcher::new_for_testing);
let worktree_path =
StandardizedPath::from_local_canonicalized(&worktree_path).unwrap();
let external_git_dir =
StandardizedPath::from_local_canonicalized(&external_git_dir).unwrap();
let raw_directory_handle = watcher_handle
.update(&mut app, |watcher, ctx| {
watcher.add_directory(worktree_path.clone(), ctx)
})
.unwrap();
raw_directory_handle.read(&app, |repository, _ctx| {
assert!(repository.external_git_directory().is_none());
});
let enriched_directory_handle = watcher_handle
.update(&mut app, |watcher, ctx| {
watcher.add_directory_with_git_dir(
worktree_path.clone(),
Some(external_git_dir.clone()),
ctx,
)
})
.unwrap();
assert_eq!(raw_directory_handle, enriched_directory_handle);
enriched_directory_handle.read(&app, |repository, _ctx| {
assert_eq!(repository.external_git_directory(), Some(&external_git_dir));
assert_eq!(repository.common_git_dir(), common_git_dir);
});
let reused_directory_handle = watcher_handle
.update(&mut app, |watcher, ctx| {
watcher.add_directory(worktree_path, ctx)
})
.unwrap();
assert_eq!(enriched_directory_handle, reused_directory_handle);
reused_directory_handle.read(&app, |repository, _ctx| {
assert_eq!(repository.external_git_directory(), Some(&external_git_dir));
});
});
},
);
}
#[test]
fn test_add_repository_non_existent() {
VirtualFS::test("add_repo_nonexistent", |dirs, _vfs| {
@@ -375,9 +433,10 @@ async fn wait_for_queue_complete(queue: ModelHandle<TaskQueue>, app: &mut App) {
#[test]
fn test_is_git_internal_path() {
use crate::entry::is_git_internal_path;
use std::path::Path;
use crate::entry::is_git_internal_path;
// .git/ internal paths should be detected
assert!(is_git_internal_path(Path::new("/repo/.git/HEAD")));
assert!(is_git_internal_path(Path::new("/repo/.git/index")));
@@ -397,7 +456,124 @@ fn test_is_git_internal_path() {
}
#[test]
#[ignore = "flaky test: CODE-1492"]
fn test_remote_tracking_ref_routes_only_to_repos_tracking_that_ref() {
VirtualFS::test("remote_tracking_ref_routes", |dirs, mut vfs| {
stub_git_repository(&mut vfs, "repo");
vfs.mkdir("repo/.git/worktrees");
vfs.mkdir("repo/.git/worktrees/wt");
vfs.mkdir("repo/.git/refs/remotes");
vfs.mkdir("repo/.git/refs/remotes/origin");
vfs.mkdir("wt");
vfs.with_files(vec![
Stub::FileWithContent("repo/.git/refs/remotes/origin/main", "abc123"),
Stub::FileWithContent("repo/.git/refs/remotes/origin/feature", "def456"),
Stub::FileWithContent("repo/.git/worktrees/wt/HEAD", "ref: refs/heads/feature"),
]);
let repo_path = dirs.tests().join("repo");
let worktree_path = dirs.tests().join("wt");
let external_git_dir = dirs.tests().join("repo/.git/worktrees/wt");
let main_remote_ref_path = dirs.tests().join("repo/.git/refs/remotes/origin/main");
let feature_remote_ref_path = dirs.tests().join("repo/.git/refs/remotes/origin/feature");
App::test((), |mut app| async move {
let watcher_handle = app.add_singleton_model(DirectoryWatcher::new_for_testing);
let main_repo_handle = watcher_handle
.update(&mut app, |watcher, ctx| {
watcher.add_directory(
StandardizedPath::from_local_canonicalized(&repo_path).unwrap(),
ctx,
)
})
.unwrap();
let worktree_repo_handle = watcher_handle
.update(&mut app, |watcher, ctx| {
watcher.add_directory_with_git_dir(
StandardizedPath::from_local_canonicalized(&worktree_path).unwrap(),
Some(
StandardizedPath::from_local_canonicalized(&external_git_dir).unwrap(),
),
ctx,
)
})
.unwrap();
main_repo_handle.update(&mut app, |repo, _| {
repo.update_tracked_remote_ref(TrackedRemoteRef::from_full_ref_name(
"refs/remotes/origin/main",
));
});
worktree_repo_handle.update(&mut app, |repo, _| {
repo.update_tracked_remote_ref(TrackedRemoteRef::from_full_ref_name(
"refs/remotes/origin/feature",
));
});
let main_affected = watcher_handle.update(&mut app, |watcher, ctx| {
watcher.find_repos_for_git_event(&main_remote_ref_path, ctx)
});
assert_eq!(main_affected, vec![main_repo_handle.clone()]);
let feature_affected = watcher_handle.update(&mut app, |watcher, ctx| {
watcher.find_repos_for_git_event(&feature_remote_ref_path, ctx)
});
assert_eq!(feature_affected, vec![worktree_repo_handle]);
});
});
}
#[test]
fn test_common_config_routes_to_repos_sharing_common_git_dir() {
VirtualFS::test("common_config_routes", |dirs, mut vfs| {
stub_git_repository(&mut vfs, "repo");
vfs.mkdir("repo/.git/worktrees");
vfs.mkdir("repo/.git/worktrees/wt");
vfs.mkdir("wt");
vfs.with_files(vec![Stub::FileWithContent(
"repo/.git/worktrees/wt/HEAD",
"ref: refs/heads/feature",
)]);
let repo_path = dirs.tests().join("repo");
let worktree_path = dirs.tests().join("wt");
let external_git_dir = dirs.tests().join("repo/.git/worktrees/wt");
let common_config_path = dirs.tests().join("repo/.git/config");
App::test((), |mut app| async move {
let watcher_handle = app.add_singleton_model(DirectoryWatcher::new_for_testing);
let main_repo_handle = watcher_handle
.update(&mut app, |watcher, ctx| {
watcher.add_directory(
StandardizedPath::from_local_canonicalized(&repo_path).unwrap(),
ctx,
)
})
.unwrap();
let worktree_repo_handle = watcher_handle
.update(&mut app, |watcher, ctx| {
watcher.add_directory_with_git_dir(
StandardizedPath::from_local_canonicalized(&worktree_path).unwrap(),
Some(
StandardizedPath::from_local_canonicalized(&external_git_dir).unwrap(),
),
ctx,
)
})
.unwrap();
let affected = watcher_handle.update(&mut app, |watcher, ctx| {
watcher.find_repos_for_git_event(&common_config_path, ctx)
});
assert_eq!(affected.len(), 2);
assert!(affected.contains(&main_repo_handle));
assert!(affected.contains(&worktree_repo_handle));
});
});
}
#[test]
fn test_commit_related_files_excluded_from_update_lists() {
VirtualFS::test("commit_files_excluded", |dirs, mut vfs| {
log::info!("Start setting up test vfs");
@@ -442,7 +618,14 @@ fn test_commit_related_files_excluded_from_update_lists() {
log::info!("Finished setting up watcher");
// Wait for initial scan to complete
scan_rx.next().await.expect("Scan should complete");
futures::select! {
scan = scan_rx.next().fuse() => {
scan.expect("Scan channel closed while waiting for initial repository scan");
}
_ = futures::FutureExt::fuse(Timer::after(Duration::from_secs(5))) => {
panic!("Timed out waiting for initial repository scan");
}
}
log::info!("Initial scan completed");
// Update both a regular file and a commit-related file
@@ -457,52 +640,79 @@ fn test_commit_related_files_excluded_from_update_lists() {
std::fs::write(&branch_file_path, "def456abc123").expect("Updating branch ref failed");
log::info!("Wrote files: regular_file.txt, .git/HEAD, .git/refs/heads/main");
// Receive the update with timeout and retry
let update = loop {
let update_timeout = Duration::from_secs(5);
let timeout = futures::FutureExt::fuse(Timer::after(update_timeout));
futures::pin_mut!(timeout);
let mut updates = Vec::new();
loop {
if updates
.iter()
.any(|update: &RepositoryUpdate| update.commit_updated)
&& updates.iter().any(|update| {
update
.added_or_modified()
.any(|file| file.path == regular_file_path)
})
{
break;
}
futures::select! {
update = futures::FutureExt::fuse(update_rx.next()) => {
update = update_rx.next().fuse() => {
match update {
Some(update) => {
log::info!("Received update");
break update;
log::info!("Received update: {update:?}");
updates.push(update);
}
None => {
panic!("Update channel closed unexpectedly");
panic!(
"Update channel closed while waiting for watcher updates after modifying regular_file.txt, .git/HEAD, and .git/refs/heads/main. Received {} update(s): {updates:#?}",
updates.len()
);
}
}
}
_ = futures::FutureExt::fuse(Timer::after(Duration::from_secs(5))) => {
log::warn!("Waiting for update timed out after 5s, retrying...");
_ = timeout => {
panic!(
"Timed out after {update_timeout:?} waiting for watcher updates after modifying regular_file.txt, .git/HEAD, and .git/refs/heads/main. Expected at least one update with commit_updated=true and one update containing {}. Received {} update(s): {updates:#?}",
regular_file_path.display(),
updates.len()
);
}
}
};
}
// Verify that commit_updated is true
assert!(
update.commit_updated,
updates.iter().any(|update| update.commit_updated),
"commit_updated should be true when git commit files change"
);
// Verify that git files are NOT in the added list, but regular files are
use crate::TargetFile;
assert!(
update
.contains_added_or_modified(&TargetFile::new(regular_file_path.clone(), false)),
updates.iter().any(|update| update
.added_or_modified()
.any(|file| file.path == regular_file_path)),
"Regular file should be in added/modified list"
);
assert!(
!update.contains_added_or_modified(&TargetFile::new(head_file_path.clone(), false)),
updates.iter().all(|update| update
.added_or_modified()
.all(|file| file.path != head_file_path)),
"Git HEAD file should NOT be in added/modified list"
);
assert!(
!update
.contains_added_or_modified(&TargetFile::new(branch_file_path.clone(), false)),
updates.iter().all(|update| update
.added_or_modified()
.all(|file| file.path != branch_file_path)),
"Git branch ref file should NOT be in added/modified list"
);
// The update should not be considered empty due to commit_updated being true
assert!(
!update.is_empty(),
updates
.iter()
.any(|update| update.commit_updated && !update.is_empty()),
"Update should not be empty when commit_updated is true"
);
+131 -9
View File
@@ -10,16 +10,17 @@ use std::path::Path;
use galaxy_core::HostId;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::{AppContext, ModelContext, ModelHandle, SingletonEntity};
use galaxyui_core::{AppContext, ModelContext, ModelHandle, SingletonEntity};
use crate::file_tree_store::FileTreeState;
use crate::file_tree_update::RepoMetadataUpdate;
use crate::file_tree_update::{MetadataUpdateType, RepoMetadataUpdate};
use crate::local_model::{
GetContentsArgs, IndexedRepoState, LocalRepoMetadataModel, RepoContent, RepositoryMetadataEvent,
GetContentsArgs, IndexedRepoState, LocalRepoMetadataModel, RepoContents,
RepositoryMetadataEvent,
};
use crate::remote_model::{RemoteRepoMetadataModel, RemoteRepositoryMetadataEvent};
use crate::repository_identifier::{RemoteRepositoryIdentifier, RepositoryIdentifier};
use crate::RepoMetadataError;
use crate::{RepoMetadataError, StandingQueryResults, StandingQueryResultsDelta};
/// Unified events emitted by the [`RepoMetadataModel`] wrapper.
///
@@ -34,7 +35,17 @@ pub enum RepoMetadataEvent {
/// File trees for repositories were updated.
FileTreeUpdated { ids: Vec<RepositoryIdentifier> },
/// A file tree entry was updated.
FileTreeEntryUpdated { id: RepositoryIdentifier },
FileTreeEntryUpdated {
id: RepositoryIdentifier,
/// Specifies whether this event contains a precise delta or requires a conservative
/// refresh because the entry was replaced without one.
update_type: MetadataUpdateType,
},
/// Stored standing-query paths changed for a repository.
StandingQueryResultsUpdated {
id: RepositoryIdentifier,
delta: StandingQueryResultsDelta,
},
/// Updating a repository failed.
UpdatingRepositoryFailed { id: RepositoryIdentifier },
/// An incremental file tree update is ready to be sent to the remote
@@ -88,6 +99,7 @@ impl RepoMetadataModel {
fn forward_local_event(
&mut self,
_: ModelHandle<LocalRepoMetadataModel>,
event: &RepositoryMetadataEvent,
ctx: &mut ModelContext<Self>,
) {
@@ -110,9 +122,16 @@ impl RepoMetadataModel {
.collect(),
}
}
RepositoryMetadataEvent::FileTreeEntryUpdated { path } => {
RepositoryMetadataEvent::FileTreeEntryUpdated { path, update_type } => {
RepoMetadataEvent::FileTreeEntryUpdated {
id: RepositoryIdentifier::local(path.clone()),
update_type: update_type.clone(),
}
}
RepositoryMetadataEvent::StandingQueryResultsUpdated { path, delta } => {
RepoMetadataEvent::StandingQueryResultsUpdated {
id: RepositoryIdentifier::local(path.clone()),
delta: delta.clone(),
}
}
RepositoryMetadataEvent::UpdatingRepositoryFailed { path } => {
@@ -131,6 +150,7 @@ impl RepoMetadataModel {
fn forward_remote_event(
&mut self,
_: ModelHandle<RemoteRepoMetadataModel>,
event: &RemoteRepositoryMetadataEvent,
ctx: &mut ModelContext<Self>,
) {
@@ -154,9 +174,16 @@ impl RepoMetadataModel {
.collect(),
}
}
RemoteRepositoryMetadataEvent::FileTreeEntryUpdated { id } => {
RemoteRepositoryMetadataEvent::FileTreeEntryUpdated { id, update_type } => {
RepoMetadataEvent::FileTreeEntryUpdated {
id: RepositoryIdentifier::Remote(id.clone()),
update_type: update_type.clone(),
}
}
RemoteRepositoryMetadataEvent::StandingQueryResultsUpdated { id, delta } => {
RepoMetadataEvent::StandingQueryResultsUpdated {
id: RepositoryIdentifier::Remote(id.clone()),
delta: delta.clone(),
}
}
};
@@ -179,6 +206,21 @@ impl RepoMetadataModel {
}
}
pub fn standing_query_results<'a>(
&self,
id: &RepositoryIdentifier,
ctx: &'a AppContext,
) -> Option<&'a StandingQueryResults> {
match id {
RepositoryIdentifier::Local(path) => {
self.local.as_ref(ctx).standing_query_results(path)
}
RepositoryIdentifier::Remote(remote_id) => {
self.remote.as_ref(ctx).standing_query_results(remote_id)
}
}
}
/// Returns whether the given repository is indexed.
pub fn has_repository(&self, id: &RepositoryIdentifier, ctx: &AppContext) -> bool {
match id {
@@ -203,13 +245,42 @@ impl RepoMetadataModel {
}
}
/// Returns a future that resolves once repository indexing has completed at least once.
///
/// Callers should inspect [`Self::repository_state`] after awaiting this future to see whether
/// indexing succeeded or failed.
pub fn repository_indexed(
&self,
id: &RepositoryIdentifier,
ctx: &mut ModelContext<Self>,
) -> futures::future::BoxFuture<'static, ()> {
match id {
RepositoryIdentifier::Local(path) => {
let path = path.clone();
self.local
.update(ctx, |local, _| local.repository_indexed(&path))
}
RepositoryIdentifier::Remote(remote_id) => {
let remote_id = remote_id.clone();
self.remote
.update(ctx, |remote, _| remote.repository_indexed(&remote_id))
}
}
}
/// Returns repository contents for the specified repository.
///
/// The number of returned entries is capped; when the repository contains
/// more matching entries, the result is truncated and
/// [`RepoContents::truncated`] is set to `true`.
///
/// Returns an error if the repository is not indexed, indexing is pending, or indexing failed.
pub fn get_repo_contents<'a>(
&self,
id: &RepositoryIdentifier,
args: GetContentsArgs,
ctx: &'a AppContext,
) -> Option<Vec<RepoContent<'a>>> {
) -> Result<RepoContents<'a>, RepoMetadataError> {
match id {
RepositoryIdentifier::Local(path) => {
self.local.as_ref(ctx).get_repo_contents(path, args)
@@ -234,6 +305,18 @@ impl RepoMetadataModel {
// These delegate to the local sub-model. Remote equivalents will be
// added once the remote client ↔ server sync layer is in place.
/// Fully indexes a local directory identified by a standardized path.
#[cfg(feature = "local_fs")]
pub fn index_local_directory_path(
&self,
path: &StandardizedPath,
ctx: &mut ModelContext<Self>,
) -> Result<(), RepoMetadataError> {
let path = path.clone();
self.local
.update(ctx, |local, ctx| local.index_directory_path(&path, ctx))
}
/// Indexes a local repository from the given repository handle.
#[cfg(feature = "local_fs")]
pub fn index_directory(
@@ -272,6 +355,34 @@ impl RepoMetadataModel {
})
}
/// Registers paths that must be loaded even when gitignored or beyond the
/// tree's size limit.
///
/// This delegates to the local model because force-included path matching
/// happens while building local file trees. Remote repositories receive the
/// resulting file-tree metadata over the existing remote sync protocol.
pub fn register_force_included_paths(
&self,
paths: impl IntoIterator<Item = std::path::PathBuf>,
ctx: &mut ModelContext<Self>,
) {
let paths: Vec<_> = paths.into_iter().collect();
self.local.update(ctx, |local, _| {
local.register_force_included_paths(paths);
});
}
pub fn set_project_skill_provider_paths(
&self,
paths: impl IntoIterator<Item = std::path::PathBuf>,
ctx: &mut ModelContext<Self>,
) {
let paths: Vec<_> = paths.into_iter().collect();
self.local.update(ctx, |local, _| {
local.set_project_skill_provider_paths(paths);
});
}
/// Removes a lazily-loaded local standalone path from tracking.
#[cfg(feature = "local_fs")]
pub fn remove_lazy_loaded_path(&self, path: &StandardizedPath, ctx: &mut ModelContext<Self>) {
@@ -356,7 +467,7 @@ impl RepoMetadataModel {
}
}
impl galaxyui::Entity for RepoMetadataModel {
impl galaxyui_core::Entity for RepoMetadataModel {
type Event = RepoMetadataEvent;
}
@@ -375,4 +486,15 @@ impl RepoMetadataModel {
local.insert_test_state(repo_path, state);
});
}
pub fn insert_test_standing_results(
&self,
repo_path: StandardizedPath,
standing_results: StandingQueryResults,
ctx: &mut ModelContext<Self>,
) {
self.local.update(ctx, |local, _ctx| {
local.insert_test_standing_results(repo_path, standing_results);
});
}
}