first pass of merging in warp (doesn't build)
This commit is contained in:
+676
-124
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
®ular_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(®ular_dir).unwrap();
|
||||
repo_handle.read(&app, |watcher, _ctx| {
|
||||
assert!(!watcher.repository_roots.contains(®ular_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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user