Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,618 @@
|
||||
#![cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
|
||||
use ignore::gitignore::Gitignore;
|
||||
use std::io;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use thiserror::Error;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
|
||||
/// Maximum file size allowed for treesitter parsing (3MB).
|
||||
const MAX_FILE_SIZE: usize = 3 * 1000 * 1000;
|
||||
|
||||
/// Maximum number of files to load when lazy-loading a directory
|
||||
pub const LAZY_LOAD_FILE_LIMIT: usize = 5000;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BuildTreeError {
|
||||
#[error("Repo size exceeded max file limit")]
|
||||
ExceededMaxFileLimit,
|
||||
#[error("File is ignored")]
|
||||
Ignored,
|
||||
#[error("IO error reading path.")]
|
||||
IOError(#[from] io::Error),
|
||||
#[error("Symlink is not supported")]
|
||||
Symlink,
|
||||
#[error("Maximum directory depth exceeded")]
|
||||
MaxDepthExceeded,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum IgnoredPathStrategy {
|
||||
/// Do not include any ingored files or folders
|
||||
Exclude,
|
||||
|
||||
/// Lazy-load excluded directories
|
||||
IncludeLazy,
|
||||
|
||||
/// Exclude all ignored files except for the ones in the given list
|
||||
IncludeOnly(Vec<String>),
|
||||
|
||||
/// Add all of the ignored files into the tree
|
||||
Include,
|
||||
}
|
||||
|
||||
/// Filesystem entry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Entry {
|
||||
File(FileMetadata),
|
||||
Directory(DirectoryEntry),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
|
||||
pub struct FileId(usize);
|
||||
|
||||
impl FileId {
|
||||
/// Constructs a new globally-unique file ID.
|
||||
#[allow(clippy::new_without_default)]
|
||||
pub(crate) fn new() -> FileId {
|
||||
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
let raw = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
FileId(raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
pub fn path(&self) -> &StandardizedPath {
|
||||
match self {
|
||||
Self::File(file) => &file.path,
|
||||
Self::Directory(directory) => &directory.path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn loaded(&self) -> bool {
|
||||
match self {
|
||||
Self::File(_) => true,
|
||||
Self::Directory(directory) => directory.loaded,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ignored(&self) -> bool {
|
||||
match self {
|
||||
Self::File(file) => file.ignored,
|
||||
Self::Directory(directory) => directory.ignored,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// IgnoredPathStrategy determines what happens when ignored files are encountered.
|
||||
pub fn build_tree(
|
||||
path: impl Into<PathBuf>,
|
||||
files: &mut Vec<FileMetadata>,
|
||||
gitignores: &mut Vec<Gitignore>,
|
||||
mut remaining_file_quota: Option<&mut usize>,
|
||||
max_depth: usize,
|
||||
current_depth: usize,
|
||||
ignored_path_strategy: &IgnoredPathStrategy,
|
||||
) -> Result<Self, BuildTreeError> {
|
||||
let curr_path: PathBuf = path.into();
|
||||
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 = 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())
|
||||
{
|
||||
return Err(BuildTreeError::ExceededMaxFileLimit);
|
||||
}
|
||||
|
||||
if let Some(entry) = match entry {
|
||||
Ok(entry) => {
|
||||
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.
|
||||
let canonical_path = if entry_path.is_symlink() {
|
||||
if entry_path.is_dir() {
|
||||
None
|
||||
} else {
|
||||
Some(entry_path)
|
||||
}
|
||||
} else {
|
||||
dunce::canonicalize(entry_path).ok()
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
} 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
|
||||
}
|
||||
let metadata = FileMetadata::new(curr_path, path_is_ignored);
|
||||
files.push(metadata.clone());
|
||||
Ok(Self::File(metadata))
|
||||
} else {
|
||||
Err(BuildTreeError::Symlink)
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds an entry based on path
|
||||
pub fn find_mut(&mut self, path: &Path) -> Option<&mut Entry> {
|
||||
let std_path = StandardizedPath::try_from_local(path).ok()?;
|
||||
self.find_mut_by_std_path(&std_path)
|
||||
}
|
||||
|
||||
fn find_mut_by_std_path(&mut self, path: &StandardizedPath) -> Option<&mut Entry> {
|
||||
if self.path() == path {
|
||||
return Some(self);
|
||||
}
|
||||
|
||||
if let Self::Directory(directory) = self {
|
||||
if !path.starts_with(&directory.path) {
|
||||
// Target is not descendant of directory.
|
||||
return None;
|
||||
}
|
||||
|
||||
for child in directory.children.iter_mut() {
|
||||
if let Some(entry) = child.find_mut_by_std_path(path) {
|
||||
return Some(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Loads an unloaded directory
|
||||
pub fn load(&mut self, gitignores: &mut Vec<Gitignore>) -> Result<(), BuildTreeError> {
|
||||
// TODO: Consider a similar `unload` method if we run into performance issues.
|
||||
let Self::Directory(directory) = self else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut remaining_file_quota = LAZY_LOAD_FILE_LIMIT;
|
||||
let mut files = Vec::new();
|
||||
|
||||
let result = Entry::build_tree(
|
||||
directory.path.to_local_path_lossy(),
|
||||
&mut files,
|
||||
gitignores,
|
||||
Some(&mut remaining_file_quota),
|
||||
1, /* max_depth */
|
||||
0, /* current_depth */
|
||||
&IgnoredPathStrategy::Include,
|
||||
);
|
||||
|
||||
result.map(|entry| match entry {
|
||||
Entry::Directory(entry) => {
|
||||
*directory = entry;
|
||||
}
|
||||
Entry::File(_) => {
|
||||
log::error!("Called load on a directory but a file entry was returned");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes the entry corresponding to the given target path, if any.
|
||||
pub fn remove(&mut self, target_path: &Path) -> Option<FileMetadata> {
|
||||
let std_path = StandardizedPath::try_from_local(target_path).ok()?;
|
||||
self.remove_by_std_path(&std_path)
|
||||
}
|
||||
|
||||
fn remove_by_std_path(&mut self, target_path: &StandardizedPath) -> Option<FileMetadata> {
|
||||
let Self::Directory(directory) = self else {
|
||||
// We should never hit this condition - we only end up recursing into directories given
|
||||
// that recursion only occurs when `target_path` is a descendant of `directory.path`
|
||||
// but not a direct child.
|
||||
return None;
|
||||
};
|
||||
if !target_path.starts_with(&directory.path) {
|
||||
// Target is not descendant of directory.
|
||||
return None;
|
||||
}
|
||||
for (index, child) in directory.children.iter_mut().enumerate() {
|
||||
if child.path() == target_path {
|
||||
// If the child's path is the target path, remove the child.
|
||||
return match directory.children.remove(index) {
|
||||
Entry::Directory(_) => None,
|
||||
Entry::File(metadata) => Some(metadata),
|
||||
};
|
||||
} else if target_path.starts_with(child.path()) {
|
||||
// Child is a descendant of the target path, so recurse.
|
||||
return child.remove_by_std_path(target_path);
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!("target path not found under the current directory node");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_git_internal_path(path: &Path) -> bool {
|
||||
path.components().any(|component| {
|
||||
if let Component::Normal(name) = component {
|
||||
name == ".git"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns true if a path matches any of the gitignores.
|
||||
///
|
||||
/// For example, if the directory `/target` is ignored:
|
||||
/// - If `check_ancestors` is true, then `/target/debug` will match.
|
||||
/// - If `check_ancestors` is false, then `/target/debug` will not match.
|
||||
pub fn matches_gitignores(
|
||||
path: &Path,
|
||||
is_dir: bool,
|
||||
gitignores: &[Gitignore],
|
||||
check_ancestors: bool,
|
||||
) -> bool {
|
||||
gitignores.iter().any(|gitignore| {
|
||||
if let Ok(relative_path) = path.strip_prefix(gitignore.path()) {
|
||||
// `matched_path_or_any_parents` panics if the path has a root.
|
||||
// If not on windows, we allow paths with a root if the gitignore path is empty (since this denotes a global gitignore).
|
||||
if relative_path.has_root() && (cfg!(windows) || gitignore.path() != Path::new("")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if check_ancestors {
|
||||
gitignore
|
||||
.matched_path_or_any_parents(relative_path, is_dir)
|
||||
.is_ignore()
|
||||
} else {
|
||||
gitignore.matched(relative_path, is_dir).is_ignore()
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path components after `.git` in a git-internal path,
|
||||
/// skipping the worktree indirection (`.git/worktrees/<name>/…`) if present.
|
||||
/// Returns `None` if the path has no `.git` component or nothing follows it.
|
||||
fn git_suffix_components(path: &Path) -> Option<Vec<Component<'_>>> {
|
||||
let components: Vec<_> = path.components().collect();
|
||||
let git_index = components.iter().position(|c| c.as_os_str() == ".git")?;
|
||||
|
||||
let after_git = &components[git_index + 1..];
|
||||
if after_git.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// For worktrees the layout is `.git/worktrees/<name>/…`.
|
||||
// Skip the `worktrees/<name>` prefix so callers see the same
|
||||
// logical structure as a normal repo.
|
||||
if after_git.first().map(|c| c.as_os_str()) == Some(std::ffi::OsStr::new("worktrees"))
|
||||
&& after_git.len() >= 3
|
||||
{
|
||||
// after_git[0] = "worktrees", [1] = <name>, [2..] = actual content
|
||||
return Some(after_git[2..].to_vec());
|
||||
}
|
||||
|
||||
Some(after_git.to_vec())
|
||||
}
|
||||
|
||||
/// Given a path like `.../repo/.git/worktrees/foo/HEAD`, returns
|
||||
/// `.../repo/.git/worktrees/foo`. Returns `None` for non-worktree paths.
|
||||
pub(crate) fn extract_worktree_git_dir(path: &Path) -> Option<PathBuf> {
|
||||
let components: Vec<_> = path.components().collect();
|
||||
let git_index = components.iter().position(|c| c.as_os_str() == ".git")?;
|
||||
let after_git = &components[git_index + 1..];
|
||||
if after_git.len() >= 3
|
||||
&& after_git
|
||||
.first()
|
||||
.map(|c| c.as_os_str() == "worktrees")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
// Rebuild: everything up to and including .git/worktrees/<name>
|
||||
Some(components[..git_index + 3].iter().collect())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` for shared ref paths that live directly in the common
|
||||
/// `.git` directory and should be broadcast to all repos sharing it.
|
||||
/// Currently this means `.git/refs/heads/*` (not under `.git/worktrees/`).
|
||||
pub(crate) fn is_shared_git_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
|
||||
.first()
|
||||
.map(|c| c.as_os_str() == "refs")
|
||||
.unwrap_or(false)
|
||||
&& after_git
|
||||
.get(1)
|
||||
.map(|c| c.as_os_str() == "heads")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let Some(suffix) = git_suffix_components(path) else {
|
||||
return false;
|
||||
};
|
||||
match suffix.first().map(|c| c.as_os_str()) {
|
||||
Some(name) if name == "HEAD" => true,
|
||||
Some(name) if name == "refs" => {
|
||||
suffix.get(1).map(|c| c.as_os_str()) == Some(std::ffi::OsStr::new("heads"))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true for `.git/index.lock`
|
||||
/// (and its worktree equivalent `.git/worktrees/*/index.lock`).
|
||||
pub(crate) fn is_index_lock_file(path: &Path) -> bool {
|
||||
let Some(suffix) = git_suffix_components(path) else {
|
||||
return false;
|
||||
};
|
||||
suffix.len() == 1 && suffix[0].as_os_str() == "index.lock"
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
|
||||
!matches_gitignores(
|
||||
&to_check_path,
|
||||
to_check_path.is_dir(),
|
||||
gitignores,
|
||||
true, /* check_ancestors */
|
||||
) && !should_ignore_git_path(&to_check_path)
|
||||
}
|
||||
|
||||
/// Determines whether a file should be parsed by a treesitter query. For now the main criteria is it shouldn't
|
||||
/// exceed the given file size limit.
|
||||
pub fn is_file_parsable(path: &Path) -> Result<bool, io::Error> {
|
||||
std::fs::metadata(path).map(|metadata| (metadata.len() as usize) < MAX_FILE_SIZE)
|
||||
}
|
||||
|
||||
pub fn gitignores_for_directory(directory_path: &Path) -> Vec<Gitignore> {
|
||||
let mut gitignores = Vec::new();
|
||||
let gitignore_path = directory_path.join(".gitignore");
|
||||
if gitignore_path.exists() {
|
||||
let (gitignore, _) = Gitignore::new(&gitignore_path);
|
||||
gitignores.push(gitignore);
|
||||
}
|
||||
let (global_gitignore, _) = Gitignore::global();
|
||||
if !global_gitignore.is_empty() {
|
||||
gitignores.push(global_gitignore);
|
||||
}
|
||||
gitignores
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileMetadata {
|
||||
/// Absolute path to the file.
|
||||
pub path: StandardizedPath,
|
||||
pub file_id: FileId,
|
||||
pub extension: Option<String>,
|
||||
pub ignored: bool,
|
||||
}
|
||||
|
||||
impl FileMetadata {
|
||||
pub fn new(path: PathBuf, ignored: bool) -> Self {
|
||||
let path_extension = path.extension().and_then(|extension| extension.to_str());
|
||||
let file_id = FileId::new();
|
||||
let std_path = StandardizedPath::from_local_absolute_unchecked(&path);
|
||||
Self {
|
||||
file_id,
|
||||
extension: path_extension.map(str::to_string),
|
||||
path: std_path,
|
||||
ignored,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct from a [`StandardizedPath`] directly, without filesystem I/O.
|
||||
pub fn from_standardized(path: StandardizedPath, ignored: bool) -> Self {
|
||||
let file_id = FileId::new();
|
||||
let extension = path.extension().map(|s| s.to_owned());
|
||||
Self {
|
||||
file_id,
|
||||
extension,
|
||||
path,
|
||||
ignored,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DirectoryEntry {
|
||||
/// Absolute path to the directory.
|
||||
pub path: StandardizedPath,
|
||||
pub children: Vec<Entry>,
|
||||
pub ignored: bool,
|
||||
pub loaded: bool,
|
||||
}
|
||||
|
||||
impl DirectoryEntry {
|
||||
pub fn find_or_insert_child(&mut self, target_path: &Path) -> Option<&mut Entry> {
|
||||
let std_path = StandardizedPath::try_from_local(target_path).ok()?;
|
||||
|
||||
// First, try to find the child's position
|
||||
if let Some(index) = self
|
||||
.children
|
||||
.iter()
|
||||
.position(|child| *child.path() == std_path)
|
||||
{
|
||||
// Child exists, return a mutable reference to it
|
||||
return Some(&mut self.children[index]);
|
||||
}
|
||||
|
||||
// Child not found, create new entry if the path is valid
|
||||
let new_entry = if target_path.is_dir() {
|
||||
Entry::Directory(DirectoryEntry {
|
||||
children: vec![],
|
||||
path: std_path,
|
||||
loaded: false,
|
||||
ignored: false,
|
||||
})
|
||||
} else if target_path.is_file() {
|
||||
Entry::File(FileMetadata {
|
||||
path: std_path.clone(),
|
||||
file_id: FileId::new(),
|
||||
extension: std_path.extension().map(|s| s.to_owned()),
|
||||
ignored: false,
|
||||
})
|
||||
} else {
|
||||
// Cannot insert child since target_path is neither a file or a directory.
|
||||
return None;
|
||||
};
|
||||
|
||||
// Insert the new entry and return a mutable reference to it
|
||||
self.children.push(new_entry);
|
||||
self.children.last_mut()
|
||||
}
|
||||
|
||||
/// Similar to find_or_insert_child but specifically for creating directory entries.
|
||||
/// This is used when we know the path should be a directory (e.g., when ensuring parent directories exist).
|
||||
pub fn find_or_insert_directory(&mut self, target_path: &Path) -> Option<&mut Entry> {
|
||||
let std_path = StandardizedPath::try_from_local(target_path).ok()?;
|
||||
|
||||
// First, try to find the child's position
|
||||
if let Some(index) = self
|
||||
.children
|
||||
.iter()
|
||||
.position(|child| *child.path() == std_path)
|
||||
{
|
||||
// Child exists, return a mutable reference to it
|
||||
return Some(&mut self.children[index]);
|
||||
}
|
||||
|
||||
// Child not found, create new directory entry
|
||||
let new_entry = Entry::Directory(DirectoryEntry {
|
||||
children: vec![],
|
||||
path: std_path,
|
||||
ignored: false,
|
||||
loaded: false,
|
||||
});
|
||||
|
||||
// Insert the new entry and return a mutable reference to it
|
||||
self.children.push(new_entry);
|
||||
self.children.last_mut()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "entry_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,415 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
mod file_tree_state;
|
||||
|
||||
use crate::file_tree_store::file_tree_state::FileTreeMapStore;
|
||||
use crate::{BuildTreeError, Entry, FileId, FileMetadata, Repository};
|
||||
use ignore::gitignore::Gitignore;
|
||||
use std::sync::Arc;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
use warpui::ModelHandle;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileTreeEntry {
|
||||
state_map: FileTreeMapStore,
|
||||
root_path: Arc<StandardizedPath>,
|
||||
}
|
||||
|
||||
impl FileTreeEntry {
|
||||
pub fn ignored(&self, path: &StandardizedPath) -> bool {
|
||||
let Some(entry_state) = self.state_map.get(path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match entry_state {
|
||||
FileTreeEntryState::File(file) => file.ignored,
|
||||
FileTreeEntryState::Directory(directory) => directory.ignored,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, path: &StandardizedPath) -> Option<&FileTreeEntryState> {
|
||||
self.state_map.get(path)
|
||||
}
|
||||
|
||||
pub fn contains(&self, path: &StandardizedPath) -> bool {
|
||||
self.state_map.contains(path)
|
||||
}
|
||||
|
||||
pub fn root_directory(&self) -> &Arc<StandardizedPath> {
|
||||
&self.root_path
|
||||
}
|
||||
|
||||
pub fn rename_path(&mut self, path: &StandardizedPath, new_path: &StandardizedPath) -> bool {
|
||||
self.state_map.rename_path(path, new_path)
|
||||
}
|
||||
|
||||
pub fn load_at_path(
|
||||
&mut self,
|
||||
path: &StandardizedPath,
|
||||
gitignores: &mut Vec<Gitignore>,
|
||||
) -> Result<(), BuildTreeError> {
|
||||
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);
|
||||
}
|
||||
|
||||
pub fn child_paths(
|
||||
&self,
|
||||
path: &StandardizedPath,
|
||||
) -> impl Iterator<Item = &Arc<StandardizedPath>> {
|
||||
self.state_map.children(path)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, path: &StandardizedPath) -> Option<&mut FileTreeEntryState> {
|
||||
self.state_map.get_mut(path)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, path: &StandardizedPath) {
|
||||
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()),
|
||||
root_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Similar to find_or_insert_child but specifically for creating directory entries.
|
||||
/// This is used when we know the path should be a directory (e.g., when ensuring parent directories exist).
|
||||
pub fn find_or_insert_directory(
|
||||
&mut self,
|
||||
parent_path: &StandardizedPath,
|
||||
target_path: &StandardizedPath,
|
||||
) -> Option<&mut FileTreeEntryState> {
|
||||
if self.state_map.contains_child(parent_path, target_path) {
|
||||
return self.state_map.get_mut(target_path);
|
||||
}
|
||||
|
||||
// Child not found, create new directory entry
|
||||
let new_entry = FileTreeEntryState::Directory(FileTreeDirectoryEntryState {
|
||||
path: Arc::new(target_path.clone()),
|
||||
ignored: false,
|
||||
loaded: false,
|
||||
});
|
||||
|
||||
self.state_map
|
||||
.insert_child(Arc::new(parent_path.clone()), new_entry);
|
||||
self.state_map.get_mut(target_path)
|
||||
}
|
||||
|
||||
pub fn find_parent_directory(&self, path: &StandardizedPath) -> Option<Arc<StandardizedPath>> {
|
||||
self.state_map.parent_directory(path)
|
||||
}
|
||||
|
||||
pub fn find_or_insert_child(
|
||||
&mut self,
|
||||
parent_path: &StandardizedPath,
|
||||
child_path: &std::path::Path,
|
||||
) -> Option<Arc<StandardizedPath>> {
|
||||
let std_child = StandardizedPath::try_from_local(child_path).ok()?;
|
||||
if self.state_map.contains_child(parent_path, &std_child) {
|
||||
return Some(Arc::new(std_child));
|
||||
}
|
||||
|
||||
let child_arc = Arc::new(std_child);
|
||||
let new_entry = if child_path.is_dir() {
|
||||
FileTreeEntryState::Directory(FileTreeDirectoryEntryState {
|
||||
path: child_arc.clone(),
|
||||
loaded: false,
|
||||
ignored: false,
|
||||
})
|
||||
} else if child_path.is_file() {
|
||||
FileTreeEntryState::File(FileTreeFileMetadata {
|
||||
path: child_arc.clone(),
|
||||
file_id: FileId::new(),
|
||||
extension: child_arc.extension().map(|s| s.to_owned()),
|
||||
ignored: false,
|
||||
})
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
self.state_map
|
||||
.insert_child(Arc::new(parent_path.clone()), new_entry)
|
||||
}
|
||||
|
||||
pub fn insert_child_state(
|
||||
&mut self,
|
||||
parent_path: &StandardizedPath,
|
||||
child_state: FileTreeEntryState,
|
||||
) -> Option<Arc<StandardizedPath>> {
|
||||
self.state_map
|
||||
.insert_child(Arc::new(parent_path.clone()), child_state)
|
||||
}
|
||||
|
||||
/// Ensures all ancestor directories between root and `target_parent`
|
||||
/// exist in the tree, creating unloaded directory entries as needed.
|
||||
///
|
||||
/// This is essential for handling filesystem events that reference files deep
|
||||
/// in directory hierarchies where intermediate directories might not exist in our
|
||||
/// in-memory tree yet.
|
||||
pub fn ensure_parent_directories_exist(&mut self, target_parent: &StandardizedPath) {
|
||||
let root_directory = self.root_directory();
|
||||
|
||||
// Validate that target_parent is indeed under root_entry
|
||||
if !target_parent.starts_with(root_directory) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(FileTreeEntryState::Directory(root_directory)) = self.get(root_directory).cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get all ancestors between target parent and root (exclusive of root, inclusive of target)
|
||||
let ancestors: Vec<_> = target_parent
|
||||
.ancestors()
|
||||
.take_while(|ancestor| *ancestor != *root_directory.path.as_ref())
|
||||
.collect();
|
||||
|
||||
// Create directories from root to target parent using find_or_insert_directory
|
||||
let mut current_parent = root_directory;
|
||||
for ancestor in ancestors.iter().rev() {
|
||||
match self.find_or_insert_directory(¤t_parent.path, ancestor) {
|
||||
Some(FileTreeEntryState::Directory(dir)) => {
|
||||
current_parent = dir.clone();
|
||||
}
|
||||
Some(FileTreeEntryState::File(_)) => {
|
||||
log::warn!("Found file where directory expected: {ancestor:?}");
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
log::warn!("Failed to create or find directory: {ancestor:?}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a [`RepoMetadataUpdate`] to this file tree entry.
|
||||
///
|
||||
/// Removals are processed first, then subtree patches are applied.
|
||||
/// This is the core mutation path used by the remote client to apply
|
||||
/// incremental updates received from the server.
|
||||
pub fn apply_repo_metadata_update(
|
||||
&mut self,
|
||||
update: &crate::file_tree_update::RepoMetadataUpdate,
|
||||
) {
|
||||
// 1. Process removals
|
||||
for path in &update.remove_entries {
|
||||
self.remove(path);
|
||||
}
|
||||
|
||||
// 2. Process subtree patches
|
||||
for entry_update in &update.update_entries {
|
||||
self.apply_entry_update(entry_update);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_entry_update(&mut self, update: &crate::file_tree_update::FileTreeEntryUpdate) {
|
||||
use crate::file_tree_update::RepoNodeMetadata;
|
||||
|
||||
// Ensure parent directories exist up to parent_path_to_replace
|
||||
self.ensure_parent_directories_exist(&update.parent_path_to_replace);
|
||||
|
||||
// `subtree_metadata` is in depth-first pre-order: each directory
|
||||
// appears before its children. A single pass is sufficient because
|
||||
// by the time we encounter a file, its parent directory has already
|
||||
// been inserted. `insert_child_state` also registers the child in
|
||||
// `parent_to_child_map`, so no separate wiring step is needed.
|
||||
for node in &update.subtree_metadata {
|
||||
match node {
|
||||
RepoNodeMetadata::Directory(dir) => {
|
||||
let state = FileTreeEntryState::Directory(FileTreeDirectoryEntryState {
|
||||
path: Arc::new(dir.path.clone()),
|
||||
ignored: dir.ignored,
|
||||
loaded: dir.loaded,
|
||||
});
|
||||
if let Some(parent) = self.find_parent_directory(&dir.path) {
|
||||
self.insert_child_state(&parent, state);
|
||||
} else {
|
||||
log::warn!("Could not find parent directory for node during incremental update: {:?}", dir.path);
|
||||
}
|
||||
}
|
||||
RepoNodeMetadata::File(file) => {
|
||||
// If the file already exists, preserve its FileId and just
|
||||
// update metadata (mirrors the local apply path).
|
||||
if let Some(existing) = self.get_mut(&file.path) {
|
||||
existing.set_ignored(file.ignored);
|
||||
} else {
|
||||
let state = FileTreeEntryState::File(FileTreeFileMetadata {
|
||||
path: Arc::new(file.path.clone()),
|
||||
file_id: FileId::new(),
|
||||
extension: file.extension.clone(),
|
||||
ignored: file.ignored,
|
||||
});
|
||||
if let Some(parent) = self.find_parent_directory(&file.path) {
|
||||
self.insert_child_state(&parent, state);
|
||||
} else {
|
||||
log::warn!("Could not find parent directory for node during incremental update: {:?}", file.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FileTreeEntryState {
|
||||
File(FileTreeFileMetadata),
|
||||
Directory(FileTreeDirectoryEntryState),
|
||||
}
|
||||
|
||||
impl FileTreeEntryState {
|
||||
fn as_directory(&self) -> Option<&FileTreeDirectoryEntryState> {
|
||||
match self {
|
||||
FileTreeEntryState::File(_) => None,
|
||||
FileTreeEntryState::Directory(directory) => Some(directory),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileTreeFileMetadata {
|
||||
/// Absolute path to the file.
|
||||
pub path: Arc<StandardizedPath>,
|
||||
pub file_id: FileId,
|
||||
pub extension: Option<String>,
|
||||
pub ignored: bool,
|
||||
}
|
||||
|
||||
impl From<FileMetadata> for FileTreeFileMetadata {
|
||||
fn from(value: FileMetadata) -> Self {
|
||||
Self {
|
||||
path: Arc::new(value.path),
|
||||
file_id: value.file_id,
|
||||
extension: value.extension.clone(),
|
||||
ignored: value.ignored,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileTreeEntryState {
|
||||
pub fn set_ignored(&mut self, ignored: bool) {
|
||||
match self {
|
||||
Self::File(file) => file.ignored = ignored,
|
||||
Self::Directory(directory) => directory.ignored = ignored,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ignored(&self) -> bool {
|
||||
match self {
|
||||
FileTreeEntryState::File(f) => f.ignored,
|
||||
FileTreeEntryState::Directory(d) => d.ignored,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &StandardizedPath {
|
||||
match self {
|
||||
FileTreeEntryState::File(file) => &file.path,
|
||||
FileTreeEntryState::Directory(directory) => &directory.path,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_arc(&self) -> Arc<StandardizedPath> {
|
||||
match self {
|
||||
FileTreeEntryState::File(file) => file.path.clone(),
|
||||
FileTreeEntryState::Directory(directory) => directory.path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn loaded(&self) -> bool {
|
||||
match self {
|
||||
FileTreeEntryState::File(_) => true,
|
||||
FileTreeEntryState::Directory(d) => d.loaded,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileTreeDirectoryEntryState {
|
||||
/// Absolute path to the directory.
|
||||
pub path: Arc<StandardizedPath>,
|
||||
pub ignored: bool,
|
||||
pub loaded: bool,
|
||||
}
|
||||
|
||||
impl From<Entry> for FileTreeEntry {
|
||||
fn from(value: Entry) -> Self {
|
||||
let root_path = Arc::new(value.path().clone());
|
||||
let state_map = FileTreeMapStore::from(value);
|
||||
|
||||
FileTreeEntry {
|
||||
state_map,
|
||||
root_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the state of a file tree for a specific repository.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileTreeState {
|
||||
/// The entry representing the file tree structure.
|
||||
pub entry: FileTreeEntry,
|
||||
/// Gitignore rules applicable to this repository.
|
||||
pub gitignores: Vec<Gitignore>,
|
||||
|
||||
/// Handle to the backing repository (None for lazily-loaded standalone paths).
|
||||
#[expect(unused)]
|
||||
repository: Option<ModelHandle<Repository>>,
|
||||
}
|
||||
|
||||
impl FileTreeState {
|
||||
/// Creates a new FileTreeState.
|
||||
pub fn new(
|
||||
entry: Entry,
|
||||
gitignores: Vec<Gitignore>,
|
||||
repository: Option<ModelHandle<Repository>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
entry: entry.into(),
|
||||
gitignores,
|
||||
repository,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new FileTreeState for a lazily-loaded standalone path.
|
||||
pub fn new_lazy_loaded(entry: Entry) -> Self {
|
||||
Self {
|
||||
entry: entry.into(),
|
||||
gitignores: vec![],
|
||||
repository: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new FileTreeState from a pre-built [`FileTreeEntry`].
|
||||
///
|
||||
/// Used by the remote model where the entry is constructed via
|
||||
/// `apply_repo_metadata_update` rather than from a local `Entry`.
|
||||
pub fn from_file_tree_entry(entry: FileTreeEntry) -> Self {
|
||||
Self {
|
||||
entry,
|
||||
gitignores: vec![],
|
||||
repository: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "file_tree_store_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,312 @@
|
||||
use crate::file_tree_store::FileTreeEntry;
|
||||
use crate::file_tree_store::{FileTreeDirectoryEntryState, FileTreeEntryState};
|
||||
use crate::{BuildTreeError, DirectoryEntry, Entry};
|
||||
use ignore::gitignore::Gitignore;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::iter;
|
||||
use std::sync::Arc;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct FileTreeMapStore {
|
||||
state_map: HashMap<Arc<StandardizedPath>, FileTreeEntryState>,
|
||||
parent_to_child_map: HashMap<Arc<StandardizedPath>, HashSet<Arc<StandardizedPath>>>,
|
||||
}
|
||||
|
||||
impl From<Entry> for FileTreeMapStore {
|
||||
fn from(value: Entry) -> Self {
|
||||
match value {
|
||||
Entry::File(file) => Self::new_for_entry(FileTreeEntryState::File(file.into())),
|
||||
Entry::Directory(dir) => {
|
||||
let dir_path: Arc<StandardizedPath> = Arc::new(dir.path);
|
||||
let entry = FileTreeEntryState::Directory(FileTreeDirectoryEntryState {
|
||||
path: dir_path.clone(),
|
||||
ignored: dir.ignored,
|
||||
loaded: dir.loaded,
|
||||
});
|
||||
|
||||
let mut file_tree_map = Self::new_for_entry(entry);
|
||||
for child in dir.children {
|
||||
file_tree_map.append_entry(dir_path.clone(), child);
|
||||
}
|
||||
file_tree_map
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileTreeMapStore {
|
||||
pub fn new_for_directory(directory_path: Arc<StandardizedPath>) -> Self {
|
||||
let directory = FileTreeEntryState::Directory(FileTreeDirectoryEntryState {
|
||||
path: directory_path.clone(),
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
|
||||
let state_map = HashMap::from_iter([(directory_path, directory)]);
|
||||
|
||||
Self {
|
||||
state_map,
|
||||
parent_to_child_map: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_for_entry(entry: FileTreeEntryState) -> Self {
|
||||
let state_map = HashMap::from_iter([(entry.path_arc(), entry)]);
|
||||
|
||||
Self {
|
||||
state_map,
|
||||
parent_to_child_map: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_entry(&mut self, parent_path: Arc<StandardizedPath>, entry: Entry) {
|
||||
match entry {
|
||||
Entry::File(file) => {
|
||||
self.insert_child(parent_path, FileTreeEntryState::File(file.into()));
|
||||
}
|
||||
Entry::Directory(dir) => {
|
||||
let directory_path: Arc<StandardizedPath> = Arc::new(dir.path);
|
||||
let entry = FileTreeEntryState::Directory(FileTreeDirectoryEntryState {
|
||||
path: directory_path.clone(),
|
||||
ignored: dir.ignored,
|
||||
loaded: dir.loaded,
|
||||
});
|
||||
self.insert_child(parent_path, entry);
|
||||
for child in dir.children {
|
||||
self.append_entry(directory_path.clone(), child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, path: &StandardizedPath) -> Option<FileTreeEntryState> {
|
||||
let removed = self.remove_item_and_parent(path);
|
||||
|
||||
let children = self.parent_to_child_map.remove(path).into_iter().flatten();
|
||||
for child in children {
|
||||
self.remove(&child);
|
||||
}
|
||||
|
||||
removed
|
||||
}
|
||||
|
||||
fn remove_item_and_parent(&mut self, path: &StandardizedPath) -> Option<FileTreeEntryState> {
|
||||
let removed = self.state_map.remove(path);
|
||||
|
||||
if let Some(parent) = self.parent_directory(path) {
|
||||
self.parent_to_child_map
|
||||
.entry(parent)
|
||||
.or_default()
|
||||
.remove(path);
|
||||
}
|
||||
|
||||
removed
|
||||
}
|
||||
|
||||
/// Returns the parent directory of the current path.
|
||||
///
|
||||
/// ## Validation
|
||||
/// No validation is done to ensure that the returned path is actually the parent of the child.
|
||||
pub fn parent_directory(&self, path: &StandardizedPath) -> Option<Arc<StandardizedPath>> {
|
||||
let parent_dir = path.parent()?;
|
||||
self.state_map
|
||||
.get(&parent_dir)
|
||||
.and_then(FileTreeEntryState::as_directory)?;
|
||||
Some(Arc::new(parent_dir))
|
||||
}
|
||||
|
||||
pub fn children(
|
||||
&self,
|
||||
path: &StandardizedPath,
|
||||
) -> impl Iterator<Item = &Arc<StandardizedPath>> {
|
||||
if self
|
||||
.state_map
|
||||
.get(path)
|
||||
.and_then(FileTreeEntryState::as_directory)
|
||||
.is_none()
|
||||
{
|
||||
return itertools::Either::Left(iter::empty());
|
||||
};
|
||||
|
||||
itertools::Either::Right(self.parent_to_child_map.get(path).into_iter().flatten())
|
||||
}
|
||||
|
||||
pub fn get(&self, path: &StandardizedPath) -> Option<&FileTreeEntryState> {
|
||||
self.state_map.get(path)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, path: &StandardizedPath) -> Option<&mut FileTreeEntryState> {
|
||||
self.state_map.get_mut(path)
|
||||
}
|
||||
|
||||
fn get_as_directory(&self, path: &StandardizedPath) -> Option<&FileTreeDirectoryEntryState> {
|
||||
self.state_map
|
||||
.get(path)
|
||||
.and_then(FileTreeEntryState::as_directory)
|
||||
}
|
||||
|
||||
pub fn contains(&self, path: &StandardizedPath) -> bool {
|
||||
self.state_map.contains_key(path)
|
||||
}
|
||||
|
||||
pub fn contains_child(&self, parent_path: &StandardizedPath, child: &StandardizedPath) -> bool {
|
||||
if self
|
||||
.state_map
|
||||
.get(parent_path)
|
||||
.and_then(FileTreeEntryState::as_directory)
|
||||
.is_none()
|
||||
{
|
||||
return false;
|
||||
};
|
||||
|
||||
self.parent_to_child_map
|
||||
.get(parent_path)
|
||||
.is_some_and(|children| children.contains(child))
|
||||
}
|
||||
|
||||
pub fn insert_child(
|
||||
&mut self,
|
||||
parent_path: Arc<StandardizedPath>,
|
||||
child: FileTreeEntryState,
|
||||
) -> Option<Arc<StandardizedPath>> {
|
||||
self.get_as_directory(&parent_path)?;
|
||||
|
||||
let child_path: Arc<StandardizedPath> = child.path_arc();
|
||||
self.state_map.insert(child_path.clone(), child);
|
||||
self.parent_to_child_map
|
||||
.entry(parent_path)
|
||||
.or_default()
|
||||
.insert(child_path.clone());
|
||||
|
||||
Some(child_path)
|
||||
}
|
||||
|
||||
pub fn load_at_path(
|
||||
&mut self,
|
||||
path: &StandardizedPath,
|
||||
gitignores: &mut Vec<Gitignore>,
|
||||
) -> Result<(), BuildTreeError> {
|
||||
let child_path: Arc<StandardizedPath> = Arc::new(path.clone());
|
||||
let mut entry = Entry::Directory(DirectoryEntry {
|
||||
path: path.clone(),
|
||||
children: vec![],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
|
||||
entry.load(gitignores)?;
|
||||
self.insert_entry_at_path(child_path, entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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);
|
||||
self.parent_to_child_map
|
||||
.extend(child_entry_map.state_map.parent_to_child_map);
|
||||
|
||||
// ATODO test this
|
||||
if let Some(parent) = self.parent_directory(&path) {
|
||||
self.parent_to_child_map
|
||||
.entry(parent)
|
||||
.or_default()
|
||||
.insert(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renames the path in the tree from `path` to `new_path`.
|
||||
pub fn rename_path(&mut self, path: &StandardizedPath, new_path: &StandardizedPath) -> bool {
|
||||
// 1. Check if old path exists
|
||||
let old_path_arc = if let Some((k, _)) = self.state_map.get_key_value(path) {
|
||||
k.clone()
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// 2. Check if new parent exists
|
||||
let Some(new_parent_path) = self.parent_directory(new_path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// 3. Remove from old parent's child list and from state map
|
||||
let Some(mut item) = self.remove_item_and_parent(path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// 3. Update item path
|
||||
let new_path_arc: Arc<StandardizedPath> = Arc::new(new_path.clone());
|
||||
match &mut item {
|
||||
FileTreeEntryState::File(f) => f.path = new_path_arc.clone(),
|
||||
FileTreeEntryState::Directory(d) => d.path = new_path_arc.clone(),
|
||||
}
|
||||
let is_directory = matches!(item, FileTreeEntryState::Directory(_));
|
||||
|
||||
// 6. Insert into state_map with new key
|
||||
self.state_map.insert(new_path_arc.clone(), item);
|
||||
|
||||
// 7. Add to new parent's child list
|
||||
self.parent_to_child_map
|
||||
.entry(new_parent_path)
|
||||
.or_default()
|
||||
.insert(new_path_arc.clone());
|
||||
|
||||
// 8. If directory, handle children
|
||||
if is_directory {
|
||||
// Move children list in parent_to_child_map
|
||||
if let Some(children) = self.parent_to_child_map.remove(&old_path_arc) {
|
||||
self.parent_to_child_map
|
||||
.insert(new_path_arc.clone(), children.clone());
|
||||
|
||||
// Recursively update paths for all descendants
|
||||
for child_path in children {
|
||||
self.update_paths_recursive(child_path, path, new_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn update_paths_recursive(
|
||||
&mut self,
|
||||
path: Arc<StandardizedPath>,
|
||||
from: &StandardizedPath,
|
||||
to: &StandardizedPath,
|
||||
) {
|
||||
let Some(relative) = path.strip_prefix(from) else {
|
||||
return;
|
||||
};
|
||||
let new_child_path = Arc::new(to.join(relative));
|
||||
|
||||
// Remove old entry
|
||||
if let Some(mut item) = self.remove_item_and_parent(&path) {
|
||||
match &mut item {
|
||||
FileTreeEntryState::File(f) => f.path = new_child_path.clone(),
|
||||
FileTreeEntryState::Directory(d) => d.path = new_child_path.clone(),
|
||||
}
|
||||
// Insert with new key.
|
||||
self.state_map.insert(new_child_path.clone(), item);
|
||||
|
||||
// Update parent_to_child_map if it's a directory.
|
||||
if let Some(children) = self.parent_to_child_map.remove(&path) {
|
||||
self.parent_to_child_map
|
||||
.insert(new_child_path.clone(), children.clone());
|
||||
for child in children {
|
||||
self.update_paths_recursive(child, from, to);
|
||||
}
|
||||
}
|
||||
|
||||
// Update parent's reference to this child
|
||||
if let Some(parent) = new_child_path.parent() {
|
||||
let parent_arc = Arc::new(parent);
|
||||
self.parent_to_child_map
|
||||
.entry(parent_arc)
|
||||
.and_modify(|children| {
|
||||
children.remove(&path);
|
||||
children.insert(new_child_path.clone());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
use crate::entry::{DirectoryEntry, Entry, FileId, FileMetadata};
|
||||
use crate::file_tree_store::{FileTreeEntry, FileTreeEntryState};
|
||||
use std::sync::Arc;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
|
||||
fn std_path(s: &str) -> StandardizedPath {
|
||||
StandardizedPath::try_new(s).expect("test path should be valid")
|
||||
}
|
||||
|
||||
fn create_file_entry(path: &str) -> Entry {
|
||||
let sp = std_path(path);
|
||||
Entry::File(FileMetadata {
|
||||
path: sp.clone(),
|
||||
file_id: FileId::new(),
|
||||
extension: sp.extension().map(|s| s.to_owned()),
|
||||
ignored: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_dir_entry(path: &str) -> Entry {
|
||||
Entry::Directory(DirectoryEntry {
|
||||
path: std_path(path),
|
||||
children: vec![],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_file() {
|
||||
let root = std_path("/repo");
|
||||
let mut tree = FileTreeEntry::new_for_directory(Arc::new(root.clone()));
|
||||
|
||||
let file = std_path("/repo/file.txt");
|
||||
let file_entry = create_file_entry("/repo/file.txt");
|
||||
|
||||
tree.insert_entry_at_path(Arc::new(file.clone()), file_entry);
|
||||
|
||||
assert!(tree.get(&file).is_some());
|
||||
|
||||
tree.remove(&file);
|
||||
|
||||
assert!(tree.get(&file).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_directory_with_children() {
|
||||
let root = std_path("/repo");
|
||||
let mut tree = FileTreeEntry::new_for_directory(Arc::new(root));
|
||||
|
||||
let dir = std_path("/repo/src");
|
||||
let file = std_path("/repo/src/main.rs");
|
||||
|
||||
tree.insert_entry_at_path(Arc::new(dir.clone()), create_dir_entry("/repo/src"));
|
||||
tree.insert_entry_at_path(
|
||||
Arc::new(file.clone()),
|
||||
create_file_entry("/repo/src/main.rs"),
|
||||
);
|
||||
|
||||
assert!(tree.get(&dir).is_some());
|
||||
assert!(tree.get(&file).is_some());
|
||||
|
||||
tree.remove(&dir);
|
||||
|
||||
assert!(tree.get(&dir).is_none());
|
||||
assert!(tree.get(&file).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_file() {
|
||||
let root = std_path("/repo");
|
||||
let mut tree = FileTreeEntry::new_for_directory(Arc::new(root));
|
||||
|
||||
let old = std_path("/repo/old.txt");
|
||||
let new = std_path("/repo/new.txt");
|
||||
|
||||
tree.insert_entry_at_path(Arc::new(old.clone()), create_file_entry("/repo/old.txt"));
|
||||
|
||||
assert!(tree.get(&old).is_some());
|
||||
|
||||
tree.rename_path(&old, &new);
|
||||
|
||||
assert!(tree.get(&old).is_none());
|
||||
|
||||
let new_entry = tree.get(&new);
|
||||
assert!(new_entry.is_some());
|
||||
if let Some(FileTreeEntryState::File(f)) = new_entry {
|
||||
assert_eq!(f.path.as_str(), "/repo/new.txt");
|
||||
} else {
|
||||
panic!("Expected file entry");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_directory_recursive() {
|
||||
let root = std_path("/repo");
|
||||
let mut tree = FileTreeEntry::new_for_directory(Arc::new(root));
|
||||
|
||||
let old_dir = std_path("/repo/old_src");
|
||||
let new_dir = std_path("/repo/new_src");
|
||||
let child = std_path("/repo/old_src/main.rs");
|
||||
let new_child = std_path("/repo/new_src/main.rs");
|
||||
|
||||
tree.insert_entry_at_path(Arc::new(old_dir.clone()), create_dir_entry("/repo/old_src"));
|
||||
tree.insert_entry_at_path(
|
||||
Arc::new(child.clone()),
|
||||
create_file_entry("/repo/old_src/main.rs"),
|
||||
);
|
||||
|
||||
assert!(tree.get(&old_dir).is_some());
|
||||
assert!(tree.get(&child).is_some());
|
||||
|
||||
let result = tree.rename_path(&old_dir, &new_dir);
|
||||
assert!(result, "Rename should succeed");
|
||||
|
||||
assert!(tree.get(&old_dir).is_none(), "Old directory should be gone");
|
||||
assert!(tree.get(&child).is_none(), "Old child should be gone");
|
||||
|
||||
let new_dir_entry = tree.get(&new_dir);
|
||||
assert!(new_dir_entry.is_some(), "New directory should exist");
|
||||
if let Some(FileTreeEntryState::Directory(d)) = new_dir_entry {
|
||||
assert_eq!(d.path.as_str(), "/repo/new_src");
|
||||
} else {
|
||||
panic!("Expected directory entry");
|
||||
}
|
||||
|
||||
let new_child_entry = tree.get(&new_child);
|
||||
assert!(new_child_entry.is_some(), "New child should exist");
|
||||
if let Some(FileTreeEntryState::File(f)) = new_child_entry {
|
||||
assert_eq!(f.path.as_str(), "/repo/new_src/main.rs");
|
||||
} else {
|
||||
panic!("Expected file entry");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_nested_children_recursively() {
|
||||
let root = std_path("/repo");
|
||||
let mut tree = FileTreeEntry::new_for_directory(Arc::new(root));
|
||||
|
||||
let foo = std_path("/repo/foo");
|
||||
let bar = std_path("/repo/foo/bar");
|
||||
let bazz = std_path("/repo/foo/bar/bazz");
|
||||
let buzz = std_path("/repo/foo/bar/bazz/buzz.rs");
|
||||
|
||||
tree.insert_entry_at_path(Arc::new(foo.clone()), create_dir_entry("/repo/foo"));
|
||||
tree.insert_entry_at_path(Arc::new(bar.clone()), create_dir_entry("/repo/foo/bar"));
|
||||
tree.insert_entry_at_path(
|
||||
Arc::new(bazz.clone()),
|
||||
create_dir_entry("/repo/foo/bar/bazz"),
|
||||
);
|
||||
tree.insert_entry_at_path(
|
||||
Arc::new(buzz.clone()),
|
||||
create_file_entry("/repo/foo/bar/bazz/buzz.rs"),
|
||||
);
|
||||
|
||||
assert!(tree.get(&foo).is_some());
|
||||
assert!(tree.get(&bar).is_some());
|
||||
assert!(tree.get(&bazz).is_some());
|
||||
assert!(tree.get(&buzz).is_some());
|
||||
|
||||
tree.remove(&foo);
|
||||
|
||||
assert!(tree.get(&foo).is_none(), "foo should be gone");
|
||||
assert!(tree.get(&bar).is_none(), "bar should be gone");
|
||||
assert!(tree.get(&bazz).is_none(), "bazz should be gone");
|
||||
assert!(tree.get(&buzz).is_none(), "buzz.rs should be gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_directory_parent_child_link_consistency() {
|
||||
let root = std_path("/repo");
|
||||
let mut tree = FileTreeEntry::new_for_directory(Arc::new(root));
|
||||
|
||||
let old_dir = std_path("/repo/old_src");
|
||||
let new_dir = std_path("/repo/new_src");
|
||||
let child = std_path("/repo/old_src/main.rs");
|
||||
|
||||
tree.insert_entry_at_path(Arc::new(old_dir.clone()), create_dir_entry("/repo/old_src"));
|
||||
tree.insert_entry_at_path(
|
||||
Arc::new(child.clone()),
|
||||
create_file_entry("/repo/old_src/main.rs"),
|
||||
);
|
||||
|
||||
let result = tree.rename_path(&old_dir, &new_dir);
|
||||
assert!(result, "Rename should succeed");
|
||||
|
||||
let children: Vec<_> = tree.child_paths(&new_dir).collect();
|
||||
let new_child = std_path("/repo/new_src/main.rs");
|
||||
|
||||
assert_eq!(children.len(), 1, "New directory should have 1 child");
|
||||
assert_eq!(
|
||||
children[0].as_str(),
|
||||
new_child.as_str(),
|
||||
"Child path in parent_to_child_map should match the renamed child path"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Serializable incremental file tree update types.
|
||||
//!
|
||||
//! These types mirror the protobuf schema for `RepoMetadataUpdate` 1:1,
|
||||
//! making encode/decode between Rust and proto trivial. They are produced
|
||||
//! by the server after processing watcher events and consumed by the client
|
||||
//! to update its [`RemoteRepoMetadataModel`](crate::remote_model::RemoteRepoMetadataModel).
|
||||
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
|
||||
use crate::entry::{DirectoryEntry, Entry, FileMetadata};
|
||||
|
||||
/// Mirrors `RepoMetadataUpdate` proto.
|
||||
///
|
||||
/// A batch of incremental changes for a single repository. Removals are
|
||||
/// processed before additions so that "move" semantics (remove old + add new)
|
||||
/// work correctly.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RepoMetadataUpdate {
|
||||
/// Which repository this update targets.
|
||||
pub repo_path: StandardizedPath,
|
||||
/// Paths to remove from the tree.
|
||||
pub remove_entries: Vec<StandardizedPath>,
|
||||
/// Subtree patches to add or replace in the tree.
|
||||
pub update_entries: Vec<FileTreeEntryUpdate>,
|
||||
}
|
||||
|
||||
/// Mirrors `FileTreeEntry` proto.
|
||||
///
|
||||
/// Describes a subtree patch rooted at a specific parent directory.
|
||||
/// Applying this inserts the described nodes under `parent_path_to_replace`.
|
||||
///
|
||||
/// Parent→child relationships are not included because they are derived
|
||||
/// implicitly: each node's parent is determined by its path, and
|
||||
/// `insert_child_state` registers the child in `parent_to_child_map`
|
||||
/// during application.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileTreeEntryUpdate {
|
||||
/// The parent directory whose subtree is being patched.
|
||||
pub parent_path_to_replace: StandardizedPath,
|
||||
/// Metadata for each node in the subtree.
|
||||
/// Directories must appear before their children (depth-first pre-order)
|
||||
/// so that parent entries exist when child entries are inserted.
|
||||
pub subtree_metadata: Vec<RepoNodeMetadata>,
|
||||
}
|
||||
|
||||
/// Mirrors `RepoNodeMetadata` proto.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RepoNodeMetadata {
|
||||
Directory(DirectoryNodeMetadata),
|
||||
File(FileNodeMetadata),
|
||||
}
|
||||
|
||||
/// Mirrors `DirectoryNodeMetadata` proto.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DirectoryNodeMetadata {
|
||||
pub path: StandardizedPath,
|
||||
pub ignored: bool,
|
||||
pub loaded: bool,
|
||||
}
|
||||
|
||||
/// Mirrors `FileNodeMetadata` proto.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileNodeMetadata {
|
||||
pub path: StandardizedPath,
|
||||
pub extension: Option<String>,
|
||||
pub ignored: bool,
|
||||
}
|
||||
|
||||
/// Flattens a recursive [`Entry`] into a `Vec<RepoNodeMetadata>` in
|
||||
/// depth-first pre-order (directories before their children).
|
||||
pub fn flatten_entry_metadata(entry: &Entry) -> Vec<RepoNodeMetadata> {
|
||||
let mut metadata = Vec::new();
|
||||
collect_metadata(entry, &mut metadata);
|
||||
metadata
|
||||
}
|
||||
|
||||
fn collect_metadata(entry: &Entry, metadata: &mut Vec<RepoNodeMetadata>) {
|
||||
match entry {
|
||||
Entry::File(file) => {
|
||||
metadata.push(file_metadata_to_node(file));
|
||||
}
|
||||
Entry::Directory(dir) => {
|
||||
collect_directory_metadata(dir, metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_directory_metadata(dir: &DirectoryEntry, metadata: &mut Vec<RepoNodeMetadata>) {
|
||||
// Emit metadata for the directory itself before its children.
|
||||
metadata.push(RepoNodeMetadata::Directory(DirectoryNodeMetadata {
|
||||
path: dir.path.clone(),
|
||||
ignored: dir.ignored,
|
||||
loaded: dir.loaded,
|
||||
}));
|
||||
|
||||
for child in &dir.children {
|
||||
collect_metadata(child, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
fn file_metadata_to_node(file: &FileMetadata) -> RepoNodeMetadata {
|
||||
RepoNodeMetadata::File(FileNodeMetadata {
|
||||
path: file.path.clone(),
|
||||
extension: file.extension.clone(),
|
||||
ignored: file.ignored,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "file_tree_update_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,607 @@
|
||||
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 std::path::{Path, PathBuf};
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Platform-appropriate absolute root for test paths.
|
||||
/// On Windows `/repo` is not a valid local absolute path, so we use
|
||||
/// a drive-letter prefix instead.
|
||||
#[cfg(windows)]
|
||||
const TEST_REPO_ROOT: &str = "C:\\repo";
|
||||
#[cfg(not(windows))]
|
||||
const TEST_REPO_ROOT: &str = "/repo";
|
||||
|
||||
/// Creates a `StandardizedPath` from a Unix-style test path like
|
||||
/// `"/repo/src/main.rs"`, replacing the `/repo` prefix with the
|
||||
/// platform-appropriate [`TEST_REPO_ROOT`].
|
||||
fn std_path(unix_path: &str) -> StandardizedPath {
|
||||
let local = unix_path.replacen("/repo", TEST_REPO_ROOT, 1);
|
||||
StandardizedPath::try_from_local(Path::new(&local)).unwrap()
|
||||
}
|
||||
|
||||
/// Like [`std_path`] but returns a `PathBuf` suitable for use inside
|
||||
/// `FileTreeMutation` (which carries local filesystem paths).
|
||||
fn mutation_path(unix_path: &str) -> PathBuf {
|
||||
PathBuf::from(unix_path.replacen("/repo", TEST_REPO_ROOT, 1))
|
||||
}
|
||||
|
||||
fn file(path: &str) -> Entry {
|
||||
Entry::File(FileMetadata {
|
||||
path: std_path(path),
|
||||
file_id: FileId::new(),
|
||||
extension: std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_string),
|
||||
ignored: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn dir(path: &str, children: Vec<Entry>) -> Entry {
|
||||
Entry::Directory(DirectoryEntry {
|
||||
path: std_path(path),
|
||||
children,
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn ignored_file(path: &str) -> Entry {
|
||||
Entry::File(FileMetadata {
|
||||
path: std_path(path),
|
||||
file_id: FileId::new(),
|
||||
extension: None,
|
||||
ignored: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_tree_from_entry(entry: Entry) -> FileTreeEntry {
|
||||
FileTreeEntry::from(entry)
|
||||
}
|
||||
|
||||
// ── flatten_entry_metadata tests ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn flatten_single_file() {
|
||||
let entry = file("/repo/src/main.rs");
|
||||
let metadata = flatten_entry_metadata(&entry);
|
||||
|
||||
assert_eq!(metadata.len(), 1);
|
||||
assert!(matches!(
|
||||
&metadata[0],
|
||||
RepoNodeMetadata::File(f) if f.path == std_path("/repo/src/main.rs")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_directory_with_children_is_depth_first_preorder() {
|
||||
let entry = dir(
|
||||
"/repo/src",
|
||||
vec![
|
||||
dir(
|
||||
"/repo/src/components",
|
||||
vec![
|
||||
file("/repo/src/components/button.rs"),
|
||||
file("/repo/src/components/modal.rs"),
|
||||
],
|
||||
),
|
||||
file("/repo/src/main.rs"),
|
||||
],
|
||||
);
|
||||
|
||||
let metadata = flatten_entry_metadata(&entry);
|
||||
|
||||
assert_eq!(metadata.len(), 5);
|
||||
assert!(
|
||||
matches!(&metadata[0], RepoNodeMetadata::Directory(d) if d.path == std_path("/repo/src"))
|
||||
);
|
||||
assert!(
|
||||
matches!(&metadata[1], RepoNodeMetadata::Directory(d) if d.path == std_path("/repo/src/components"))
|
||||
);
|
||||
assert!(
|
||||
matches!(&metadata[2], RepoNodeMetadata::File(f) if f.path == std_path("/repo/src/components/button.rs"))
|
||||
);
|
||||
assert!(
|
||||
matches!(&metadata[3], RepoNodeMetadata::File(f) if f.path == std_path("/repo/src/components/modal.rs"))
|
||||
);
|
||||
assert!(
|
||||
matches!(&metadata[4], RepoNodeMetadata::File(f) if f.path == std_path("/repo/src/main.rs"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatten_preserves_ignored_flag() {
|
||||
let entry = dir("/repo", vec![ignored_file("/repo/secret.env")]);
|
||||
|
||||
let metadata = flatten_entry_metadata(&entry);
|
||||
assert_eq!(metadata.len(), 2);
|
||||
assert!(matches!(
|
||||
&metadata[1],
|
||||
RepoNodeMetadata::File(f) if f.ignored
|
||||
));
|
||||
}
|
||||
|
||||
// ── apply_file_tree_mutations update-generation tests ─────────────────
|
||||
|
||||
#[test]
|
||||
fn apply_mutations_generates_update_for_remove() {
|
||||
use crate::local_model::FileTreeMutation;
|
||||
|
||||
let initial = dir("/repo", vec![file("/repo/old.rs")]);
|
||||
let mut tree = build_tree_from_entry(initial);
|
||||
let mutations = vec![FileTreeMutation::Remove(mutation_path("/repo/old.rs"))];
|
||||
|
||||
let update =
|
||||
LocalRepoMetadataModel::apply_file_tree_mutations(&mut tree, mutations, false, true)
|
||||
.expect("update should be produced");
|
||||
|
||||
assert_eq!(update.remove_entries.len(), 1);
|
||||
assert_eq!(update.remove_entries[0], std_path("/repo/old.rs"));
|
||||
assert!(update.update_entries.is_empty());
|
||||
}
|
||||
|
||||
#[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);
|
||||
let mutations = vec![FileTreeMutation::AddFile {
|
||||
path: mutation_path("/repo/src/new.rs"),
|
||||
is_ignored: false,
|
||||
extension: Some("rs".to_string()),
|
||||
}];
|
||||
|
||||
let update =
|
||||
LocalRepoMetadataModel::apply_file_tree_mutations(&mut tree, mutations, false, true)
|
||||
.expect("update should be produced");
|
||||
|
||||
assert!(update.remove_entries.is_empty());
|
||||
assert_eq!(update.update_entries.len(), 1);
|
||||
assert_eq!(
|
||||
update.update_entries[0].parent_path_to_replace,
|
||||
std_path("/repo/src")
|
||||
);
|
||||
assert_eq!(update.update_entries[0].subtree_metadata.len(), 1);
|
||||
assert!(matches!(
|
||||
&update.update_entries[0].subtree_metadata[0],
|
||||
RepoNodeMetadata::File(f) if f.path == std_path("/repo/src/new.rs")
|
||||
&& f.extension == Some("rs".to_string())
|
||||
&& !f.ignored
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_mutations_generates_update_for_add_directory_subtree() {
|
||||
use crate::local_model::FileTreeMutation;
|
||||
|
||||
let subtree = dir(
|
||||
"/repo/src/components",
|
||||
vec![
|
||||
file("/repo/src/components/button.rs"),
|
||||
file("/repo/src/components/modal.rs"),
|
||||
],
|
||||
);
|
||||
|
||||
let initial = dir("/repo", vec![dir("/repo/src", vec![])]);
|
||||
let mut tree = build_tree_from_entry(initial);
|
||||
let mutations = vec![FileTreeMutation::AddDirectorySubtree {
|
||||
dir_path: mutation_path("/repo/src/components"),
|
||||
subtree,
|
||||
}];
|
||||
|
||||
let update =
|
||||
LocalRepoMetadataModel::apply_file_tree_mutations(&mut tree, mutations, false, true)
|
||||
.expect("update should be produced");
|
||||
|
||||
assert!(update.remove_entries.is_empty());
|
||||
assert_eq!(update.update_entries.len(), 1);
|
||||
let entry_update = &update.update_entries[0];
|
||||
assert_eq!(entry_update.parent_path_to_replace, std_path("/repo/src"));
|
||||
// 1 dir + 2 files
|
||||
assert_eq!(entry_update.subtree_metadata.len(), 3);
|
||||
assert!(matches!(
|
||||
&entry_update.subtree_metadata[0],
|
||||
RepoNodeMetadata::Directory(d) if d.path == std_path("/repo/src/components")
|
||||
));
|
||||
}
|
||||
|
||||
#[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 {
|
||||
path: mutation_path("/repo/src/empty"),
|
||||
is_ignored: true,
|
||||
}];
|
||||
|
||||
let update =
|
||||
LocalRepoMetadataModel::apply_file_tree_mutations(&mut tree, mutations, false, true)
|
||||
.expect("update should be produced");
|
||||
|
||||
assert!(update.remove_entries.is_empty());
|
||||
assert_eq!(update.update_entries.len(), 1);
|
||||
assert!(matches!(
|
||||
&update.update_entries[0].subtree_metadata[0],
|
||||
RepoNodeMetadata::Directory(d) if d.path == std_path("/repo/src/empty")
|
||||
&& d.ignored
|
||||
&& !d.loaded
|
||||
));
|
||||
}
|
||||
|
||||
#[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);
|
||||
let mutations = vec![
|
||||
FileTreeMutation::Remove(mutation_path("/repo/old.rs")),
|
||||
FileTreeMutation::AddFile {
|
||||
path: mutation_path("/repo/new.rs"),
|
||||
is_ignored: false,
|
||||
extension: Some("rs".to_string()),
|
||||
},
|
||||
FileTreeMutation::AddEmptyDirectory {
|
||||
path: mutation_path("/repo/new_dir"),
|
||||
is_ignored: false,
|
||||
},
|
||||
];
|
||||
|
||||
let update =
|
||||
LocalRepoMetadataModel::apply_file_tree_mutations(&mut tree, mutations, false, true)
|
||||
.expect("update should be produced");
|
||||
|
||||
assert_eq!(update.remove_entries.len(), 1);
|
||||
assert_eq!(update.update_entries.len(), 2);
|
||||
}
|
||||
|
||||
#[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);
|
||||
let mutations = vec![FileTreeMutation::Remove(mutation_path("/repo/old.rs"))];
|
||||
|
||||
let update =
|
||||
LocalRepoMetadataModel::apply_file_tree_mutations(&mut tree, mutations, false, false);
|
||||
|
||||
assert!(
|
||||
update.is_none(),
|
||||
"should return None when emit_updates is false"
|
||||
);
|
||||
assert!(
|
||||
tree.get(&std_path("/repo/old.rs")).is_none(),
|
||||
"old.rs should still be removed from the tree"
|
||||
);
|
||||
}
|
||||
|
||||
// ── apply_repo_metadata_update tests
|
||||
|
||||
#[test]
|
||||
fn apply_complete_update_adds_files_and_directories() {
|
||||
let initial = dir("/repo", vec![dir("/repo/src", vec![])]);
|
||||
let mut tree = build_tree_from_entry(initial);
|
||||
|
||||
let update = RepoMetadataUpdate {
|
||||
repo_path: std_path("/repo"),
|
||||
remove_entries: vec![],
|
||||
update_entries: vec![FileTreeEntryUpdate {
|
||||
parent_path_to_replace: std_path("/repo/src"),
|
||||
subtree_metadata: vec![
|
||||
RepoNodeMetadata::Directory(DirectoryNodeMetadata {
|
||||
path: std_path("/repo/src/components"),
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
}),
|
||||
RepoNodeMetadata::File(FileNodeMetadata {
|
||||
path: std_path("/repo/src/components/button.rs"),
|
||||
extension: Some("rs".to_string()),
|
||||
ignored: false,
|
||||
}),
|
||||
RepoNodeMetadata::File(FileNodeMetadata {
|
||||
path: std_path("/repo/src/main.rs"),
|
||||
extension: Some("rs".to_string()),
|
||||
ignored: false,
|
||||
}),
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
tree.apply_repo_metadata_update(&update);
|
||||
|
||||
assert!(
|
||||
matches!(tree.get(&std_path("/repo/src/components")), Some(FileTreeEntryState::Directory(d)) if d.loaded),
|
||||
"components/ should exist and be loaded"
|
||||
);
|
||||
assert!(
|
||||
tree.get(&std_path("/repo/src/components/button.rs"))
|
||||
.is_some(),
|
||||
"button.rs should exist"
|
||||
);
|
||||
assert!(
|
||||
tree.get(&std_path("/repo/src/main.rs")).is_some(),
|
||||
"main.rs should exist"
|
||||
);
|
||||
let src_children: Vec<_> = tree.child_paths(&std_path("/repo/src")).collect();
|
||||
assert_eq!(src_children.len(), 2, "src/ should have 2 children");
|
||||
let comp_children: Vec<_> = tree
|
||||
.child_paths(&std_path("/repo/src/components"))
|
||||
.collect();
|
||||
assert_eq!(comp_children.len(), 1, "components/ should have 1 child");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_update_with_removals_and_additions() {
|
||||
let initial = dir(
|
||||
"/repo",
|
||||
vec![dir("/repo/src", vec![file("/repo/src/old.rs")])],
|
||||
);
|
||||
let mut tree = build_tree_from_entry(initial);
|
||||
|
||||
assert!(tree.get(&std_path("/repo/src/old.rs")).is_some());
|
||||
|
||||
let update = RepoMetadataUpdate {
|
||||
repo_path: std_path("/repo"),
|
||||
remove_entries: vec![std_path("/repo/src/old.rs")],
|
||||
update_entries: vec![FileTreeEntryUpdate {
|
||||
parent_path_to_replace: std_path("/repo/src"),
|
||||
subtree_metadata: vec![RepoNodeMetadata::File(FileNodeMetadata {
|
||||
path: std_path("/repo/src/new.rs"),
|
||||
extension: Some("rs".to_string()),
|
||||
ignored: false,
|
||||
})],
|
||||
}],
|
||||
};
|
||||
|
||||
tree.apply_repo_metadata_update(&update);
|
||||
|
||||
assert!(
|
||||
tree.get(&std_path("/repo/src/old.rs")).is_none(),
|
||||
"old.rs should be removed"
|
||||
);
|
||||
assert!(
|
||||
tree.get(&std_path("/repo/src/new.rs")).is_some(),
|
||||
"new.rs should be added"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_incomplete_update_missing_children_subtree() {
|
||||
let initial = dir("/repo", vec![dir("/repo/src", vec![])]);
|
||||
let mut tree = build_tree_from_entry(initial);
|
||||
|
||||
let update = RepoMetadataUpdate {
|
||||
repo_path: std_path("/repo"),
|
||||
remove_entries: vec![],
|
||||
update_entries: vec![FileTreeEntryUpdate {
|
||||
parent_path_to_replace: std_path("/repo/src"),
|
||||
subtree_metadata: vec![RepoNodeMetadata::Directory(DirectoryNodeMetadata {
|
||||
path: std_path("/repo/src/components"),
|
||||
ignored: false,
|
||||
loaded: false,
|
||||
})],
|
||||
}],
|
||||
};
|
||||
|
||||
tree.apply_repo_metadata_update(&update);
|
||||
|
||||
let entry = tree.get(&std_path("/repo/src/components"));
|
||||
assert!(
|
||||
matches!(entry, Some(FileTreeEntryState::Directory(d)) if !d.loaded),
|
||||
"components/ should exist but be unloaded"
|
||||
);
|
||||
let children: Vec<_> = tree
|
||||
.child_paths(&std_path("/repo/src/components"))
|
||||
.collect();
|
||||
assert!(
|
||||
children.is_empty(),
|
||||
"components/ should have no children yet"
|
||||
);
|
||||
|
||||
let followup = RepoMetadataUpdate {
|
||||
repo_path: std_path("/repo"),
|
||||
remove_entries: vec![],
|
||||
update_entries: vec![FileTreeEntryUpdate {
|
||||
parent_path_to_replace: std_path("/repo/src/components"),
|
||||
subtree_metadata: vec![RepoNodeMetadata::File(FileNodeMetadata {
|
||||
path: std_path("/repo/src/components/button.rs"),
|
||||
extension: Some("rs".to_string()),
|
||||
ignored: false,
|
||||
})],
|
||||
}],
|
||||
};
|
||||
|
||||
tree.apply_repo_metadata_update(&followup);
|
||||
|
||||
assert!(
|
||||
tree.get(&std_path("/repo/src/components/button.rs"))
|
||||
.is_some(),
|
||||
"button.rs should now exist after followup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_incomplete_update_missing_parent_from_undelivered_page() {
|
||||
let initial = dir("/repo", vec![dir("/repo/src", vec![])]);
|
||||
let mut tree = build_tree_from_entry(initial);
|
||||
|
||||
let update = RepoMetadataUpdate {
|
||||
repo_path: std_path("/repo"),
|
||||
remove_entries: vec![],
|
||||
update_entries: vec![FileTreeEntryUpdate {
|
||||
parent_path_to_replace: std_path("/repo/src/components/ui"),
|
||||
subtree_metadata: vec![RepoNodeMetadata::File(FileNodeMetadata {
|
||||
path: std_path("/repo/src/components/ui/button.rs"),
|
||||
extension: Some("rs".to_string()),
|
||||
ignored: false,
|
||||
})],
|
||||
}],
|
||||
};
|
||||
|
||||
tree.apply_repo_metadata_update(&update);
|
||||
|
||||
assert!(
|
||||
tree.get(&std_path("/repo/src/components")).is_some(),
|
||||
"intermediate components/ should be created"
|
||||
);
|
||||
assert!(
|
||||
tree.get(&std_path("/repo/src/components/ui")).is_some(),
|
||||
"intermediate ui/ should be created"
|
||||
);
|
||||
assert!(
|
||||
tree.get(&std_path("/repo/src/components/ui/button.rs"))
|
||||
.is_some(),
|
||||
"button.rs should be inserted under the created parents"
|
||||
);
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
tree.get(&std_path("/repo/src/components")),
|
||||
Some(FileTreeEntryState::Directory(d)) if !d.loaded
|
||||
),
|
||||
"auto-created components/ should be unloaded"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Round-trip test: apply mutations on server, then apply update on client ───
|
||||
|
||||
#[test]
|
||||
fn round_trip_apply_mutations_then_apply_update_produces_equivalent_tree() {
|
||||
use crate::local_model::FileTreeMutation;
|
||||
|
||||
let server_tree_entry = dir(
|
||||
"/repo",
|
||||
vec![dir("/repo/src", vec![file("/repo/src/lib.rs")])],
|
||||
);
|
||||
let mut server_tree = build_tree_from_entry(server_tree_entry);
|
||||
|
||||
let subtree = dir(
|
||||
"/repo/src/util",
|
||||
vec![
|
||||
file("/repo/src/util/helpers.rs"),
|
||||
file("/repo/src/util/constants.rs"),
|
||||
],
|
||||
);
|
||||
let mutations = vec![
|
||||
FileTreeMutation::Remove(mutation_path("/repo/src/lib.rs")),
|
||||
FileTreeMutation::AddDirectorySubtree {
|
||||
dir_path: mutation_path("/repo/src/util"),
|
||||
subtree,
|
||||
},
|
||||
FileTreeMutation::AddFile {
|
||||
path: mutation_path("/repo/src/main.rs"),
|
||||
is_ignored: false,
|
||||
extension: Some("rs".to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
let update =
|
||||
LocalRepoMetadataModel::apply_file_tree_mutations(&mut server_tree, mutations, false, true)
|
||||
.expect("update should be produced");
|
||||
|
||||
let client_tree_entry = dir(
|
||||
"/repo",
|
||||
vec![dir("/repo/src", vec![file("/repo/src/lib.rs")])],
|
||||
);
|
||||
let mut client_tree = build_tree_from_entry(client_tree_entry);
|
||||
|
||||
client_tree.apply_repo_metadata_update(&update);
|
||||
|
||||
assert!(
|
||||
client_tree.get(&std_path("/repo/src/lib.rs")).is_none(),
|
||||
"lib.rs should be removed"
|
||||
);
|
||||
assert!(
|
||||
client_tree.get(&std_path("/repo/src/util")).is_some(),
|
||||
"util/ should exist"
|
||||
);
|
||||
assert!(
|
||||
client_tree
|
||||
.get(&std_path("/repo/src/util/helpers.rs"))
|
||||
.is_some(),
|
||||
"helpers.rs should exist"
|
||||
);
|
||||
assert!(
|
||||
client_tree
|
||||
.get(&std_path("/repo/src/util/constants.rs"))
|
||||
.is_some(),
|
||||
"constants.rs should exist"
|
||||
);
|
||||
assert!(
|
||||
client_tree.get(&std_path("/repo/src/main.rs")).is_some(),
|
||||
"main.rs should exist"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lazy-load filtering test ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn lazy_load_filters_mutations_for_unloaded_parents() {
|
||||
use crate::local_model::FileTreeMutation;
|
||||
|
||||
let initial = Entry::Directory(DirectoryEntry {
|
||||
path: std_path("/repo"),
|
||||
children: vec![
|
||||
Entry::Directory(DirectoryEntry {
|
||||
path: std_path("/repo/src"),
|
||||
children: vec![],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
}),
|
||||
Entry::Directory(DirectoryEntry {
|
||||
path: std_path("/repo/vendor"),
|
||||
children: vec![],
|
||||
ignored: false,
|
||||
loaded: false,
|
||||
}),
|
||||
],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let mut tree = build_tree_from_entry(initial);
|
||||
|
||||
let mutations = vec![
|
||||
FileTreeMutation::AddFile {
|
||||
path: mutation_path("/repo/src/main.rs"),
|
||||
is_ignored: false,
|
||||
extension: Some("rs".to_string()),
|
||||
},
|
||||
FileTreeMutation::AddFile {
|
||||
path: mutation_path("/repo/vendor/lib.rs"),
|
||||
is_ignored: false,
|
||||
extension: Some("rs".to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
let update =
|
||||
LocalRepoMetadataModel::apply_file_tree_mutations(&mut tree, mutations, true, true)
|
||||
.expect("update should be produced when emit_updates is true");
|
||||
|
||||
assert!(
|
||||
tree.get(&std_path("/repo/src/main.rs")).is_some(),
|
||||
"main.rs under loaded src/ should exist"
|
||||
);
|
||||
assert!(
|
||||
tree.get(&std_path("/repo/vendor/lib.rs")).is_none(),
|
||||
"lib.rs under unloaded vendor/ should NOT exist"
|
||||
);
|
||||
|
||||
assert!(update.remove_entries.is_empty());
|
||||
assert_eq!(
|
||||
update.update_entries.len(),
|
||||
1,
|
||||
"update should only contain the applied mutation"
|
||||
);
|
||||
assert!(matches!(
|
||||
&update.update_entries[0].subtree_metadata[0],
|
||||
RepoNodeMetadata::File(f) if f.path == std_path("/repo/src/main.rs")
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Repository metadata utilities for Warp.
|
||||
//!
|
||||
//! 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 thiserror::Error;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
/// Errors that can occur when working with repository metadata.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RepoMetadataError {
|
||||
#[error("Repository not found: {0}")]
|
||||
RepoNotFound(String),
|
||||
#[error("Invalid path: {0}")]
|
||||
InvalidPath(String),
|
||||
#[error("Path encoding does not match local OS: {0}")]
|
||||
PathEncodingMismatch(StandardizedPath),
|
||||
#[error("IO error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
#[error("Build tree error: {0}")]
|
||||
BuildTree(BuildTreeError),
|
||||
#[error("Failed to start watcher: {0}")]
|
||||
WatcherError(#[from] anyhow::Error),
|
||||
}
|
||||
// Re-export the modules
|
||||
pub mod entry;
|
||||
pub mod file_tree_store;
|
||||
pub mod file_tree_update;
|
||||
pub mod local_model;
|
||||
pub mod remote_model;
|
||||
pub mod repositories;
|
||||
pub mod repository;
|
||||
pub mod repository_identifier;
|
||||
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,
|
||||
};
|
||||
|
||||
// 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: &warpui::AppContext) -> bool {
|
||||
use crate::repositories::DetectedRepositories;
|
||||
|
||||
DetectedRepositories::as_ref(app)
|
||||
.get_root_for_path(std::path::Path::new(path))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn is_in_repo(_path: &str, _app: &warpui::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 remote_model::RemoteRepoMetadataModel;
|
||||
pub use repository_identifier::{RemoteRepositoryIdentifier, RepositoryIdentifier};
|
||||
pub use wrapper_model::{RepoMetadataEvent, RepoMetadataModel};
|
||||
|
||||
/// A wrapper around PathBuf that ensures the path is canonicalized.
|
||||
/// This helps avoid issues with symbolic links, relative paths, and different path representations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct CanonicalizedPath(PathBuf);
|
||||
|
||||
impl std::fmt::Display for CanonicalizedPath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.display().fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl CanonicalizedPath {
|
||||
pub fn as_path_buf(&self) -> &PathBuf {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn as_path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// See [`PathBuf::pop`]
|
||||
pub fn pop(&mut self) -> bool {
|
||||
self.0.pop()
|
||||
}
|
||||
|
||||
pub fn starts_with(&self, path: &CanonicalizedPath) -> bool {
|
||||
self.0.starts_with(&path.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<PathBuf> for CanonicalizedPath {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
|
||||
let canonical = dunce::canonicalize(&value)?;
|
||||
Ok(CanonicalizedPath(canonical))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&Path> for CanonicalizedPath {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn try_from(value: &Path) -> Result<Self, Self::Error> {
|
||||
let canonical = dunce::canonicalize(value)?;
|
||||
Ok(CanonicalizedPath(canonical))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&PathBuf> for CanonicalizedPath {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn try_from(value: &PathBuf) -> Result<Self, Self::Error> {
|
||||
let canonical = dunce::canonicalize(value)?;
|
||||
Ok(CanonicalizedPath(canonical))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for CanonicalizedPath {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
let path = PathBuf::from(value);
|
||||
let canonical = dunce::canonicalize(&path)?;
|
||||
Ok(CanonicalizedPath(canonical))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CanonicalizedPath> for PathBuf {
|
||||
fn from(canonical: CanonicalizedPath) -> Self {
|
||||
canonical.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&CanonicalizedPath> for PathBuf {
|
||||
fn from(canonical: &CanonicalizedPath) -> Self {
|
||||
canonical.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// We can implement Borrow<Path> and Borrow<PathBuf> because CanonicalizedPath uses identical trait
|
||||
// implementations to those types (particularly for Hash, Ord, and Eq). If the implementations of
|
||||
// those traits differ, the Borrow implementations will no longer be sound.
|
||||
|
||||
impl Borrow<Path> for CanonicalizedPath {
|
||||
fn borrow(&self) -> &Path {
|
||||
self.0.borrow()
|
||||
}
|
||||
}
|
||||
|
||||
impl Borrow<PathBuf> for CanonicalizedPath {
|
||||
fn borrow(&self) -> &PathBuf {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CanonicalizedPath> for StandardizedPath {
|
||||
fn from(canonical: CanonicalizedPath) -> Self {
|
||||
// CanonicalizedPath is always absolute and local, so try_from_local will not fail.
|
||||
StandardizedPath::try_from_local(canonical.as_path())
|
||||
.expect("CanonicalizedPath is always absolute")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
//! Remote repository metadata model (client-side).
|
||||
//!
|
||||
//! Holds file tree state for repositories on remote servers. In this initial phase
|
||||
//! there is no syncing or indexing — state is populated externally (e.g. by a future
|
||||
//! remote client model or via test helpers).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use warp_core::HostId;
|
||||
use warpui::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 super::local_model::collect_contents_recursive;
|
||||
|
||||
/// Events emitted by the [`RemoteRepoMetadataModel`].
|
||||
#[derive(Debug)]
|
||||
pub enum RemoteRepositoryMetadataEvent {
|
||||
/// A remote repository was added or updated.
|
||||
RepositoryUpdated { id: RemoteRepositoryIdentifier },
|
||||
/// A remote repository was removed.
|
||||
RepositoryRemoved { id: RemoteRepositoryIdentifier },
|
||||
/// The file tree for remote repositories was updated.
|
||||
FileTreeUpdated {
|
||||
ids: Vec<RemoteRepositoryIdentifier>,
|
||||
},
|
||||
/// The file tree entry for a remote repository was updated.
|
||||
FileTreeEntryUpdated { id: RemoteRepositoryIdentifier },
|
||||
}
|
||||
|
||||
/// Client-side model for remote repository metadata.
|
||||
///
|
||||
/// This model holds file tree state for repositories living on remote servers.
|
||||
/// It provides the same read-only query surface as the local model, and write
|
||||
/// methods that will be the integration points for the future remote sync layer.
|
||||
///
|
||||
/// Consumers should access this through the [`RepoMetadataModel`](crate::wrapper_model::RepoMetadataModel)
|
||||
/// wrapper rather than using this type directly.
|
||||
pub struct RemoteRepoMetadataModel {
|
||||
repositories: HashMap<RemoteRepositoryIdentifier, IndexedRepoState>,
|
||||
}
|
||||
|
||||
impl RemoteRepoMetadataModel {
|
||||
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {
|
||||
repositories: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read-only query API ──────────────────────────────────────────
|
||||
|
||||
/// Returns the [`FileTreeState`] for a remote repository, if it is indexed.
|
||||
pub fn get_repository(&self, id: &RemoteRepositoryIdentifier) -> Option<&FileTreeState> {
|
||||
match self.repositories.get(id)? {
|
||||
IndexedRepoState::Indexed(state) => Some(state),
|
||||
IndexedRepoState::Pending | IndexedRepoState::Failed(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the given remote repository is indexed.
|
||||
pub fn has_repository(&self, id: &RemoteRepositoryIdentifier) -> bool {
|
||||
matches!(
|
||||
self.repositories.get(id),
|
||||
Some(IndexedRepoState::Indexed(_))
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the current [`IndexedRepoState`] for a remote repository.
|
||||
pub fn repository_state(&self, id: &RemoteRepositoryIdentifier) -> Option<&IndexedRepoState> {
|
||||
self.repositories.get(id)
|
||||
}
|
||||
|
||||
/// Returns repository contents for the specified remote repository.
|
||||
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,
|
||||
};
|
||||
let mut contents = Vec::new();
|
||||
collect_contents_recursive(
|
||||
&state.entry,
|
||||
state.entry.root_directory(),
|
||||
&mut contents,
|
||||
&args,
|
||||
);
|
||||
Some(contents)
|
||||
}
|
||||
|
||||
/// Returns all tracked remote repository identifiers, including those in
|
||||
/// `Pending` or `Failed` states. Callers that only need indexed repos
|
||||
/// should filter via [`get_repository`](Self::get_repository).
|
||||
pub fn remote_repository_ids(&self) -> impl Iterator<Item = &RemoteRepositoryIdentifier> {
|
||||
self.repositories.keys()
|
||||
}
|
||||
|
||||
// ── Write API (for future sync + test use) ───────────────────────
|
||||
|
||||
/// Inserts or replaces file tree state for a remote repository.
|
||||
pub fn insert_repository(
|
||||
&mut self,
|
||||
id: RemoteRepositoryIdentifier,
|
||||
state: FileTreeState,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.repositories
|
||||
.insert(id.clone(), IndexedRepoState::Indexed(state));
|
||||
ctx.emit(RemoteRepositoryMetadataEvent::RepositoryUpdated { id });
|
||||
}
|
||||
|
||||
/// Removes a remote repository from tracking.
|
||||
pub fn remove_repository(
|
||||
&mut self,
|
||||
id: &RemoteRepositoryIdentifier,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.repositories.remove(id).is_some() {
|
||||
ctx.emit(RemoteRepositoryMetadataEvent::RepositoryRemoved { id: id.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the file tree entry within an existing remote repository's state.
|
||||
pub fn update_file_tree_entry(
|
||||
&mut self,
|
||||
id: &RemoteRepositoryIdentifier,
|
||||
entry: FileTreeEntry,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let Some(IndexedRepoState::Indexed(state)) = self.repositories.get_mut(id) {
|
||||
state.entry = entry;
|
||||
ctx.emit(RemoteRepositoryMetadataEvent::FileTreeEntryUpdated { id: id.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts or replaces a remote repository from a snapshot update.
|
||||
///
|
||||
/// Creates a `FileTreeEntry` from the update by starting with an empty
|
||||
/// root and applying the snapshot entries, then wraps it in a
|
||||
/// `FileTreeState`. This is the primary entry point for populating
|
||||
/// remote repo state from server push events.
|
||||
pub fn insert_from_snapshot(
|
||||
&mut self,
|
||||
host_id: HostId,
|
||||
update: &RepoMetadataUpdate,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let mut entry = FileTreeEntry::new_for_directory(Arc::new(update.repo_path.clone()));
|
||||
entry.apply_repo_metadata_update(update);
|
||||
let state = FileTreeState::from_file_tree_entry(entry);
|
||||
let id = RemoteRepositoryIdentifier::new(host_id, update.repo_path.clone());
|
||||
self.insert_repository(id, state, ctx);
|
||||
}
|
||||
|
||||
/// Removes all remote repositories associated with the given host.
|
||||
pub fn remove_repositories_for_host(&mut self, host_id: &HostId, ctx: &mut ModelContext<Self>) {
|
||||
let ids_to_remove: Vec<RemoteRepositoryIdentifier> = self
|
||||
.repositories
|
||||
.keys()
|
||||
.filter(|id| id.host_id == *host_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
for id in ids_to_remove {
|
||||
self.remove_repository(&id, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an incremental update received from the remote server.
|
||||
///
|
||||
/// Looks up the repository by matching `(host_id, repo_path)` against
|
||||
/// tracked [`RemoteRepositoryIdentifier`]s, then delegates to
|
||||
/// [`FileTreeEntry::apply_repo_metadata_update`].
|
||||
pub fn apply_incremental_update(
|
||||
&mut self,
|
||||
host_id: &HostId,
|
||||
update: &RepoMetadataUpdate,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let matching_id = self
|
||||
.repositories
|
||||
.keys()
|
||||
.find(|id| id.host_id == *host_id && id.path == update.repo_path)
|
||||
.cloned();
|
||||
|
||||
let Some(id) = matching_id else {
|
||||
log::warn!(
|
||||
"No remote repository found for incremental update: {}",
|
||||
update.repo_path
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(IndexedRepoState::Indexed(state)) = self.repositories.get_mut(&id) {
|
||||
state.entry.apply_repo_metadata_update(update);
|
||||
ctx.emit(RemoteRepositoryMetadataEvent::FileTreeEntryUpdated { id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for RemoteRepoMetadataModel {
|
||||
type Event = RemoteRepositoryMetadataEvent;
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
use std::path::Path;
|
||||
use std::{collections::HashSet, future::Future, path::PathBuf};
|
||||
|
||||
#[cfg(test)]
|
||||
use virtual_fs::{Stub, VirtualFS};
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
#[cfg(test)]
|
||||
use warpui::r#async::FutureId;
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle};
|
||||
|
||||
use crate::DirectoryWatcher;
|
||||
use crate::Repository;
|
||||
use futures::future::{ready, Either};
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
/// Indicates why a repository detection event was emitted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RepoDetectionSource {
|
||||
/// User actively navigated to this repo in a terminal (via cd/pwd change).
|
||||
TerminalNavigation,
|
||||
/// Repo was detected during project rules indexing.
|
||||
ProjectRulesIndexing,
|
||||
/// Repo was detected for code review/diff state initialization.
|
||||
CodeReviewInitialization,
|
||||
/// Repo was cloned or discovered during cloud agent environment preparation.
|
||||
CloudEnvironmentPrep,
|
||||
}
|
||||
|
||||
pub enum DetectedRepositoriesEvent {
|
||||
DetectedGitRepo {
|
||||
repository: ModelHandle<Repository>,
|
||||
source: RepoDetectionSource,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
#[cfg(test)]
|
||||
/// List of spawned background tasks, for testing.
|
||||
spawned_futures: Vec<FutureId>,
|
||||
}
|
||||
|
||||
impl DetectedRepositories {
|
||||
/// 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(
|
||||
&mut self,
|
||||
active_directory: &str,
|
||||
source: RepoDetectionSource,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> impl Future<Output = Option<PathBuf>> {
|
||||
#[cfg(feature = "local_fs")]
|
||||
{
|
||||
use futures::channel::oneshot;
|
||||
|
||||
let Ok(path) = StandardizedPath::from_local_canonicalized(Path::new(active_directory))
|
||||
else {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Either::Right(ready(repository.to_local_path()));
|
||||
};
|
||||
|
||||
let local_path_for_search = path.to_local_path();
|
||||
let (tx, rx) = oneshot::channel::<Option<PathBuf>>();
|
||||
let spawned_handle = ctx.spawn(
|
||||
async move {
|
||||
if let Some(local_path) = local_path_for_search {
|
||||
find_git_repo(&local_path).await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
move |me, res, ctx| {
|
||||
if let Some(info) = res {
|
||||
if let Some(repo_root_path) = info
|
||||
.working_tree_path
|
||||
.as_ref()
|
||||
.and_then(|path| StandardizedPath::from_local_canonicalized(path).ok())
|
||||
{
|
||||
me.repository_roots.insert(repo_root_path.clone());
|
||||
|
||||
let external_git_dir = StandardizedPath::from_local_canonicalized(
|
||||
info.git_dir_path.as_path(),
|
||||
)
|
||||
.ok()
|
||||
// Only treat as external if it's outside the working tree.
|
||||
.filter(|p| !p.starts_with(&repo_root_path));
|
||||
|
||||
if let Some(repository) =
|
||||
DirectoryWatcher::handle(ctx).update(ctx, |watcher, ctx| {
|
||||
watcher
|
||||
.add_directory_with_git_dir(
|
||||
repo_root_path,
|
||||
external_git_dir,
|
||||
ctx,
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
{
|
||||
let repo_path = repository.as_ref(ctx).root_dir().to_local_path();
|
||||
ctx.emit(DetectedRepositoriesEvent::DetectedGitRepo {
|
||||
repository,
|
||||
source,
|
||||
});
|
||||
let _ = tx.send(repo_path);
|
||||
} else {
|
||||
let _ = tx.send(None);
|
||||
}
|
||||
} else {
|
||||
// No working tree path; do not treat git_dir_path as a repository path.
|
||||
let _ = tx.send(None);
|
||||
}
|
||||
} else {
|
||||
let _ = tx.send(None);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
#[cfg(not(test))]
|
||||
let _ = spawned_handle;
|
||||
|
||||
#[cfg(test)]
|
||||
self.spawned_futures.push(spawned_handle.future_id());
|
||||
|
||||
Either::Left(async move { rx.await.unwrap_or(None) })
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
{
|
||||
use futures::future::Ready;
|
||||
Either::<Ready<Option<PathBuf>>, Ready<Option<PathBuf>>>::Left(ready(None))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn spawned_futures(&self) -> &[FutureId] {
|
||||
&self.spawned_futures
|
||||
}
|
||||
|
||||
/// Given a path, return its corresdponding watched repository, if any.
|
||||
pub fn get_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)
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
current = ancestor.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DetectedRepositories {
|
||||
type Event = DetectedRepositoriesEvent;
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn insert_test_repo_root(&mut self, path: StandardizedPath) {
|
||||
self.repository_roots.insert(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a discovered Git repository.
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[derive(Debug, Clone)]
|
||||
struct GitRepoInfo {
|
||||
/// Path to the working tree, if present. None for bare repositories.
|
||||
working_tree_path: Option<PathBuf>,
|
||||
/// Path to the git directory (contains objects, refs, and index).
|
||||
/// We can watch the HEAD file for branch changes, but currently don't do so.
|
||||
git_dir_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Finds the Git repository containing the given path, if any.
|
||||
///
|
||||
/// Supports:
|
||||
/// - A .git directory containing a HEAD file: parent directory is the working tree, .git is the git dir
|
||||
/// - A <project>.git directory containing a HEAD file (bare repo): that directory is the git dir and there is no working tree
|
||||
/// - A .git file containing "gitdir: <path>": working tree is the parent directory; git dir is the parsed path (resolved if relative)
|
||||
///
|
||||
/// Traverses up to the user's $HOME directory; if no repo is found by that point, returns `None`.
|
||||
#[cfg(feature = "local_fs")]
|
||||
async fn find_git_repo(path: &Path) -> Option<GitRepoInfo> {
|
||||
let home_dir = dirs::home_dir()?;
|
||||
let mut current = path.to_owned();
|
||||
|
||||
loop {
|
||||
if current == home_dir {
|
||||
return None;
|
||||
}
|
||||
|
||||
// First, check if the current directory is a bare git repository.
|
||||
if let Some(dir_name) = current.file_name().and_then(|s| s.to_str()) {
|
||||
if dir_name.ends_with(".git") && is_valid_git_dir(¤t).await {
|
||||
return Some(GitRepoInfo {
|
||||
working_tree_path: None,
|
||||
git_dir_path: current.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for a .git directory.
|
||||
let dot_git_path = current.join(".git");
|
||||
if let Ok(dot_git_type) = async_fs::symlink_metadata(&dot_git_path)
|
||||
.await
|
||||
.map(|m| m.file_type())
|
||||
{
|
||||
if dot_git_type.is_dir() {
|
||||
// A standard repository with a .git directory.
|
||||
if is_valid_git_dir(&dot_git_path).await {
|
||||
return Some(GitRepoInfo {
|
||||
working_tree_path: Some(current.clone()),
|
||||
git_dir_path: dot_git_path,
|
||||
});
|
||||
}
|
||||
} else if dot_git_type.is_file() {
|
||||
// A potential gitfile, used by worktrees and submodules.
|
||||
if let Ok(contents) = async_fs::read_to_string(&dot_git_path).await {
|
||||
// Typical format: "gitdir: <path>\n"
|
||||
if let Some(rest) = contents.trim().strip_prefix("gitdir:") {
|
||||
let gitdir_path = PathBuf::from(rest.trim());
|
||||
let resolved_gitdir = if gitdir_path.is_absolute() {
|
||||
gitdir_path
|
||||
} else {
|
||||
current.join(gitdir_path)
|
||||
};
|
||||
if is_valid_git_dir(&resolved_gitdir).await {
|
||||
return Some(GitRepoInfo {
|
||||
working_tree_path: Some(current.clone()),
|
||||
git_dir_path: resolved_gitdir,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !current.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether the given directory is a valid Git directory by verifying it contains a HEAD file.
|
||||
#[cfg(feature = "local_fs")]
|
||||
async fn is_valid_git_dir(dir: &Path) -> bool {
|
||||
async_fs::metadata(dir.join("HEAD"))
|
||||
.await
|
||||
.map(|m| m.is_file())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Helper function to stub a git repository in a VirtualFS with the given repository directory name.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn stub_git_repository(vfs: &mut VirtualFS, repo_name: &str) {
|
||||
let objects_dir = format!("{repo_name}/.git/objects");
|
||||
vfs.mkdir(&objects_dir);
|
||||
|
||||
let head_path = format!("{repo_name}/.git/HEAD");
|
||||
let config_path = format!("{repo_name}/.git/config");
|
||||
vfs.with_files(vec![
|
||||
Stub::FileWithContent(&head_path, "ref: refs/heads/main"),
|
||||
Stub::FileWithContent(&config_path, "[core]\n\trepositoryformatversion = 0"),
|
||||
]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "repositories_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,200 @@
|
||||
use std::fs;
|
||||
|
||||
use crate::repositories::{stub_git_repository, RepoDetectionSource};
|
||||
use crate::{repositories::DetectedRepositories, watcher::DirectoryWatcher};
|
||||
use virtual_fs::{Stub, VirtualFS};
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
use warpui::App;
|
||||
|
||||
#[test]
|
||||
fn test_detect_possible_git_repo_non_existent_directory() {
|
||||
VirtualFS::test("detect_non_existent", |dirs, _vfs| {
|
||||
let non_existent_path = dirs.tests().join("non_existent_directory");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _watcher = app.add_model(DirectoryWatcher::new);
|
||||
let repo_handle = app.add_model(|_| DetectedRepositories::default());
|
||||
|
||||
repo_handle.update(&mut app, |watcher, ctx| {
|
||||
std::mem::drop(watcher.detect_possible_git_repo(
|
||||
&non_existent_path.to_string_lossy(),
|
||||
RepoDetectionSource::TerminalNavigation,
|
||||
ctx,
|
||||
));
|
||||
});
|
||||
|
||||
// Since the path doesn't exist, canonicalization fails and so there's no spawned future.
|
||||
repo_handle.read(&app, |repos, _| {
|
||||
assert!(repos.spawned_futures().is_empty());
|
||||
assert!(repos.repository_roots.is_empty());
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_possible_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![
|
||||
Stub::FileWithContent("regular_dir/file1.txt", "content1"),
|
||||
Stub::FileWithContent("regular_dir/subdir/file2.txt", "content2"),
|
||||
]);
|
||||
|
||||
let regular_dir = dirs.tests().join("regular_dir");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _watcher = app.add_model(DirectoryWatcher::new);
|
||||
let repo_handle = app.add_model(|_| DetectedRepositories::default());
|
||||
|
||||
repo_handle.update(&mut app, |watcher, ctx| {
|
||||
std::mem::drop(watcher.detect_possible_git_repo(
|
||||
®ular_dir.to_string_lossy(),
|
||||
RepoDetectionSource::TerminalNavigation,
|
||||
ctx,
|
||||
));
|
||||
});
|
||||
|
||||
repo_handle
|
||||
.update(&mut app, |watcher, ctx| {
|
||||
let future_id = watcher.spawned_futures()[0];
|
||||
ctx.await_spawned_future(future_id)
|
||||
})
|
||||
.await;
|
||||
|
||||
// Since no git repo is found, no repository should be registered.
|
||||
let regular_canonical =
|
||||
StandardizedPath::from_local_canonicalized(®ular_dir).unwrap();
|
||||
repo_handle.read(&app, |watcher, _ctx| {
|
||||
assert!(!watcher.repository_roots.contains(®ular_canonical));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_possible_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");
|
||||
|
||||
let parent_repo = dirs.tests().join("parent_repo");
|
||||
let nested_project = parent_repo.join("projects/nested_project");
|
||||
let parent_canonical_path =
|
||||
StandardizedPath::from_local_canonicalized(&parent_repo).unwrap();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let watcher_handle = app.add_singleton_model(DirectoryWatcher::new);
|
||||
let repo_handle = app.add_model(|_| DetectedRepositories::default());
|
||||
|
||||
// First, register the parent repository
|
||||
// Now, try to detect the nested git repo.
|
||||
repo_handle
|
||||
.update(&mut app, |repo, ctx| {
|
||||
std::mem::drop(repo.detect_possible_git_repo(
|
||||
&parent_repo.to_string_lossy(),
|
||||
RepoDetectionSource::TerminalNavigation,
|
||||
ctx,
|
||||
));
|
||||
let future_id = repo.spawned_futures().last().unwrap();
|
||||
ctx.await_spawned_future(*future_id)
|
||||
})
|
||||
.await;
|
||||
|
||||
// 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())
|
||||
.is_some());
|
||||
});
|
||||
|
||||
// Now simulate creating a nested git repo in the projects/nested_project directory.
|
||||
// This would happen in real life when someone runs `git init` in a subdirectory.
|
||||
stub_git_repository(&mut vfs, "parent_repo/projects/nested_project");
|
||||
|
||||
// Now, try to detect the nested git repo.
|
||||
repo_handle
|
||||
.update(&mut app, |repo, ctx| {
|
||||
std::mem::drop(repo.detect_possible_git_repo(
|
||||
&nested_project.to_string_lossy(),
|
||||
RepoDetectionSource::TerminalNavigation,
|
||||
ctx,
|
||||
));
|
||||
let future_id = repo.spawned_futures().last().unwrap();
|
||||
ctx.await_spawned_future(*future_id)
|
||||
})
|
||||
.await;
|
||||
|
||||
// Verify that both repositories are now registered.
|
||||
let nested_canonical_path =
|
||||
StandardizedPath::from_local_canonicalized(&nested_project).unwrap();
|
||||
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())
|
||||
.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())
|
||||
.is_some());
|
||||
});
|
||||
|
||||
// Check a subdirectory of the nested repo. This path must exist for canonicalization to succeed.
|
||||
let nested_project_contents = nested_project.join("src");
|
||||
fs::create_dir(&nested_project_contents).expect("Creating nested directory failed");
|
||||
|
||||
// Verify that querying from the nested directory returns the nested repo, not the parent
|
||||
watcher_handle.read(&app, |watcher, ctx| {
|
||||
let found_handle = watcher.get_watched_directory_for_path(&nested_project_contents);
|
||||
assert!(found_handle.is_some());
|
||||
|
||||
assert_eq!(
|
||||
found_handle.unwrap().as_ref(ctx).root_dir(),
|
||||
&nested_canonical_path
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn test_find_git_repo_with_worktree() {
|
||||
VirtualFS::test("find_git_repo_worktree", |dirs, mut vfs| {
|
||||
// Set up a primary repository with a worktree directory.
|
||||
vfs.mkdir("main_repo/.git/objects");
|
||||
vfs.mkdir("main_repo/.git/worktrees/wt1");
|
||||
vfs.with_files(vec![
|
||||
Stub::FileWithContent("main_repo/.git/HEAD", "ref: refs/heads/main"),
|
||||
// Worktree-specific gitdir contains a HEAD file
|
||||
Stub::FileWithContent("main_repo/.git/worktrees/wt1/HEAD", "ref: refs/heads/main"),
|
||||
]);
|
||||
|
||||
// Set up the worktree checkout directory with a gitfile pointing to the worktree gitdir.
|
||||
vfs.mkdir("checkout_wt1/src");
|
||||
vfs.with_files(vec![Stub::FileWithContent(
|
||||
"checkout_wt1/.git",
|
||||
"gitdir: ../main_repo/.git/worktrees/wt1",
|
||||
)]);
|
||||
|
||||
let worktree_root = dirs.tests().join("checkout_wt1");
|
||||
let expected_gitdir = dirs.tests().join("main_repo/.git/worktrees/wt1");
|
||||
|
||||
App::test((), |mut _app| async move {
|
||||
let result = super::find_git_repo(worktree_root.as_path()).await;
|
||||
assert!(result.is_some(), "expected to find a git repo for worktree");
|
||||
let info = result.unwrap();
|
||||
|
||||
// The working tree path should be the worktree checkout directory
|
||||
assert_eq!(
|
||||
info.working_tree_path.as_deref(),
|
||||
Some(worktree_root.as_path())
|
||||
);
|
||||
|
||||
// The git_dir_path should resolve to the worktree's gitdir inside the primary repo.
|
||||
let actual = std::fs::canonicalize(&info.git_dir_path).expect("canonicalize gitdir");
|
||||
let expected = std::fs::canonicalize(&expected_gitdir).expect("canonicalize expected");
|
||||
assert_eq!(actual, expected);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
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;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use ignore::gitignore::Gitignore;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
use warpui::r#async::{BoxFuture, SpawnedFutureHandle};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use warpui::SingletonEntity;
|
||||
use warpui::{Entity, ModelContext, ModelHandle};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::watcher::DirectoryWatcher;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::{
|
||||
entry::{matches_gitignores, should_ignore_git_path},
|
||||
gitignores_for_directory,
|
||||
};
|
||||
use crate::{watcher::TaskQueue, RepoMetadataError, RepositoryUpdate};
|
||||
|
||||
/// Trait for entities that want to subscribe to repository file changes.
|
||||
pub trait RepositorySubscriber: Send + Sync {
|
||||
/// Called when the subscriber is first added to build initial state.
|
||||
/// Returns a Future that completes when the scan is finished.
|
||||
fn on_scan(
|
||||
&mut self,
|
||||
repository: &Repository,
|
||||
ctx: &mut ModelContext<Repository>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
|
||||
|
||||
/// Called when file changes are detected in the repository.
|
||||
/// Returns a Future that completes once updates are processed.
|
||||
fn on_files_updated(
|
||||
&mut self,
|
||||
repository: &Repository,
|
||||
update: &RepositoryUpdate,
|
||||
ctx: &mut ModelContext<Repository>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
|
||||
|
||||
fn on_unsubscribe(&mut self, _ctx: &mut ModelContext<Repository>) {}
|
||||
}
|
||||
|
||||
/// A unique identifier for repository subscribers.
|
||||
pub type SubscriberId = usize;
|
||||
|
||||
pub struct StartWatching {
|
||||
pub subscriber_id: SubscriberId,
|
||||
pub registration_future: BoxFuture<'static, Result<(), RepoMetadataError>>,
|
||||
}
|
||||
|
||||
/// Model for tracking a code repository that Warp is aware of.
|
||||
pub struct Repository {
|
||||
/// The root directory of the repository.
|
||||
root_dir: StandardizedPath,
|
||||
/// External git directory path (e.g., for worktrees). This is the
|
||||
/// path to the **exact** per-worktree gitdir (e.g. `.git/worktrees/foo`).
|
||||
/// For the main worktree this is `None` (the gitdir is `root_dir/.git`).
|
||||
external_git_directory: Option<StandardizedPath>,
|
||||
/// The shared `.git` root directory that all worktrees of the same repo
|
||||
/// have in common. Derived from `external_git_directory` by walking up to
|
||||
/// the `.git` component. `None` when the repo is not a linked worktree.
|
||||
common_git_directory: Option<StandardizedPath>,
|
||||
/// Collection of subscribers interested in file changes.
|
||||
subscribers: HashMap<SubscriberId, Box<dyn RepositorySubscriber>>,
|
||||
/// Counter for generating unique subscriber IDs.
|
||||
next_subscriber_id: SubscriberId,
|
||||
/// Cached gitignore patterns for this repository.
|
||||
#[cfg(feature = "local_fs")]
|
||||
gitignores: Vec<Gitignore>,
|
||||
|
||||
task_queue: ModelHandle<TaskQueue>,
|
||||
}
|
||||
|
||||
impl Repository {
|
||||
/// Creates a new Repository instance.
|
||||
pub(super) fn new(
|
||||
root_dir: StandardizedPath,
|
||||
external_git_directory: Option<StandardizedPath>,
|
||||
task_queue: ModelHandle<TaskQueue>,
|
||||
) -> Self {
|
||||
#[cfg(feature = "local_fs")]
|
||||
let gitignores = {
|
||||
let local_path = root_dir.to_local_path_lossy();
|
||||
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)
|
||||
});
|
||||
|
||||
Self {
|
||||
root_dir,
|
||||
external_git_directory,
|
||||
common_git_directory,
|
||||
subscribers: HashMap::new(),
|
||||
next_subscriber_id: 0,
|
||||
#[cfg(feature = "local_fs")]
|
||||
gitignores,
|
||||
task_queue,
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk ancestors of the given path to find the `.git` component and return
|
||||
/// it as the shared git root. For example,
|
||||
/// `/repo/.git/worktrees/foo` → `/repo/.git`.
|
||||
fn derive_common_git_dir(external_git_dir: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
for ancestor in external_git_dir.ancestors() {
|
||||
if ancestor.file_name().and_then(|n| n.to_str()) == Some(".git") {
|
||||
return Some(ancestor.to_path_buf());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The root directory of this repository.
|
||||
pub fn root_dir(&self) -> &StandardizedPath {
|
||||
&self.root_dir
|
||||
}
|
||||
|
||||
/// The external git directory of this repository, if any.
|
||||
/// This is used for worktrees where the .git directory is external to the working tree.
|
||||
pub fn external_git_directory(&self) -> Option<&StandardizedPath> {
|
||||
self.external_git_directory.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the path to the actual `.git` directory for this repository.
|
||||
///
|
||||
/// For normal repositories this is `root_dir/.git`. For worktrees, the
|
||||
/// `.git` entry in the working tree is a file (not a directory), so this
|
||||
/// returns the resolved `external_git_directory` instead.
|
||||
/// Subscribers should use this for per-worktree files like `index.lock`.
|
||||
pub fn git_dir(&self) -> std::path::PathBuf {
|
||||
self.external_git_directory
|
||||
.as_ref()
|
||||
.and_then(|d| d.to_local_path())
|
||||
.unwrap_or_else(|| self.root_dir.to_local_path_lossy().join(".git"))
|
||||
}
|
||||
|
||||
/// Returns the shared `.git` root directory.
|
||||
///
|
||||
/// For normal repos this is the same as `git_dir()`. For linked worktrees
|
||||
/// this is the common `.git` directory that all worktrees share (e.g.
|
||||
/// `/repo/.git`), distinct from the per-worktree gitdir.
|
||||
pub fn common_git_dir(&self) -> std::path::PathBuf {
|
||||
self.common_git_directory
|
||||
.as_ref()
|
||||
.and_then(|d| d.to_local_path())
|
||||
.unwrap_or_else(|| self.git_dir())
|
||||
}
|
||||
|
||||
/// Returns the current watcher count.
|
||||
pub fn watcher_count(&self) -> usize {
|
||||
self.subscribers.len()
|
||||
}
|
||||
|
||||
/// Starts watching this repository with the given subscriber.
|
||||
///
|
||||
/// If this is the first subscriber, the repository root will be added to the
|
||||
/// RepositoryWatcher's set of watched paths.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
|
||||
pub fn start_watching(
|
||||
&mut self,
|
||||
subscriber: Box<dyn RepositorySubscriber>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> StartWatching {
|
||||
let subscriber_id = self.next_subscriber_id;
|
||||
self.next_subscriber_id += 1;
|
||||
|
||||
// If this is the first subscriber, we need to start watching the repository
|
||||
#[cfg(feature = "local_fs")]
|
||||
let should_start_watching = self.subscribers.is_empty();
|
||||
|
||||
self.subscribers.insert(subscriber_id, subscriber);
|
||||
|
||||
#[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()];
|
||||
|
||||
// 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)
|
||||
}))
|
||||
} else {
|
||||
Box::pin(ready(Ok(())))
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
let registration_future: BoxFuture<'static, Result<(), RepoMetadataError>> =
|
||||
Box::pin(async move { Ok(()) });
|
||||
|
||||
let self_handle = ctx.handle();
|
||||
self.task_queue.update(ctx, |queue, ctx| {
|
||||
queue.enqueue_scan(self_handle, subscriber_id, ctx);
|
||||
});
|
||||
|
||||
StartWatching {
|
||||
subscriber_id,
|
||||
registration_future,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops watching this repository for the given subscriber.
|
||||
///
|
||||
/// If this was the last subscriber, the repository root will be removed from the
|
||||
/// RepositoryWatcher's set of watched paths.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
|
||||
pub fn stop_watching(&mut self, subscriber_id: SubscriberId, ctx: &mut ModelContext<Self>) {
|
||||
let Some(mut subscriber) = self.subscribers.remove(&subscriber_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
subscriber.on_unsubscribe(ctx);
|
||||
|
||||
if self.subscribers.is_empty() {
|
||||
// If this was the last subscriber, notify the RepWatcher to stop watching.
|
||||
log::debug!(
|
||||
"All subscribers removed for {}, stopping watcher",
|
||||
self.root_dir
|
||||
);
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls scan on a specific subscriber if it exists. Returns Some(Future) if the subscriber exists, None otherwise.
|
||||
pub(crate) fn scan_subscriber(
|
||||
&mut self,
|
||||
subscriber_id: SubscriberId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Option<Pin<Box<dyn Future<Output = ()> + Send + 'static>>> {
|
||||
if let Some(mut subscriber) = self.subscribers.remove(&subscriber_id) {
|
||||
let future = subscriber.on_scan(self, ctx);
|
||||
self.subscribers.insert(subscriber_id, subscriber);
|
||||
Some(future)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Notifies a specific subscriber about file changes.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn notify_subscriber(
|
||||
&mut self,
|
||||
subscriber_id: SubscriberId,
|
||||
update: &RepositoryUpdate,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Option<Pin<Box<dyn Future<Output = ()> + Send + 'static>>> {
|
||||
if let Some(mut subscriber) = self.subscribers.remove(&subscriber_id) {
|
||||
let future = subscriber.on_files_updated(self, update, ctx);
|
||||
self.subscribers.insert(subscriber_id, subscriber);
|
||||
Some(future)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the subscriber IDs for this repository.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn get_subscriber_ids(&self) -> Vec<SubscriberId> {
|
||||
self.subscribers.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Checks if a path is gitignored within this repository.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn check_gitignore_status(&self, path: &Path) -> bool {
|
||||
// Check if path is a .git internal file
|
||||
if should_ignore_git_path(path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if path matches gitignore patterns
|
||||
let is_dir = path.is_dir();
|
||||
matches_gitignores(path, is_dir, &self.gitignores, true)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Repository {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
/// Coalescing merge for RepositoryUpdate with normalization rules.
|
||||
fn merge_repository_updates(acc: &mut RepositoryUpdate, incoming: &RepositoryUpdate) {
|
||||
// 1) Moves first
|
||||
for (to, from) in &incoming.moved {
|
||||
if acc.added.remove(from) {
|
||||
acc.added.insert(to.clone());
|
||||
return;
|
||||
}
|
||||
if acc.modified.remove(from) {
|
||||
acc.modified.insert(to.clone());
|
||||
return;
|
||||
}
|
||||
|
||||
// Collapse chain: if `from` was a prior destination, pull its original source
|
||||
let original_from = if let Some(prev_from) = acc.moved.remove(from) {
|
||||
prev_from
|
||||
} else {
|
||||
from.clone()
|
||||
};
|
||||
acc.moved.insert(to.clone(), original_from);
|
||||
}
|
||||
|
||||
// 2) Adds next
|
||||
for p in &incoming.added {
|
||||
acc.deleted.remove(p);
|
||||
acc.moved.remove(p);
|
||||
acc.modified.remove(p);
|
||||
acc.added.insert(p.clone());
|
||||
}
|
||||
|
||||
// 3) Modifies next
|
||||
for p in &incoming.modified {
|
||||
if acc.added.contains(p) {
|
||||
continue;
|
||||
}
|
||||
acc.deleted.remove(p);
|
||||
acc.moved.remove(p);
|
||||
acc.modified.insert(p.clone());
|
||||
}
|
||||
|
||||
// 4) Deletes last
|
||||
for p in &incoming.deleted {
|
||||
// Added then removed within window => cancel
|
||||
if acc.added.remove(p) {
|
||||
continue;
|
||||
}
|
||||
|
||||
acc.modified.remove(p);
|
||||
|
||||
// Removing a move target => delete original source instead
|
||||
if let Some(from) = acc.moved.remove(p) {
|
||||
acc.deleted.insert(from);
|
||||
continue;
|
||||
}
|
||||
// Deleting the source of a recorded move is redundant; move already implies source removal
|
||||
let is_from_of_some_move = acc.moved.values().any(|f| f == p);
|
||||
if is_from_of_some_move {
|
||||
continue;
|
||||
}
|
||||
acc.deleted.insert(p.clone());
|
||||
}
|
||||
|
||||
acc.commit_updated |= incoming.commit_updated;
|
||||
acc.index_lock_detected |= incoming.index_lock_detected;
|
||||
}
|
||||
|
||||
/// A generic debouncing layer for any RepositorySubscriber.
|
||||
pub struct BufferingRepositorySubscriber<S> {
|
||||
inner: Arc<Mutex<S>>,
|
||||
state: Arc<Mutex<BufferState>>,
|
||||
debounce: Duration,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BufferState {
|
||||
pending: RepositoryUpdate,
|
||||
/// Monotonic counter incremented for each incoming update; used to implement true debounce.
|
||||
version: u64,
|
||||
/// Whether the background flusher loop is currently running.
|
||||
flush_handle: Option<SpawnedFutureHandle>,
|
||||
}
|
||||
|
||||
impl<S> BufferingRepositorySubscriber<S> {
|
||||
pub fn new(inner: S, debounce: Duration) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(inner)),
|
||||
state: Arc::new(Mutex::new(BufferState::default())),
|
||||
debounce,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> RepositorySubscriber for BufferingRepositorySubscriber<S>
|
||||
where
|
||||
S: RepositorySubscriber + Send + Sync + 'static,
|
||||
{
|
||||
fn on_scan(
|
||||
&mut self,
|
||||
repository: &Repository,
|
||||
ctx: &mut ModelContext<Repository>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
|
||||
self.inner.lock().unwrap().on_scan(repository, ctx)
|
||||
}
|
||||
|
||||
fn on_files_updated(
|
||||
&mut self,
|
||||
_repository: &Repository,
|
||||
update: &RepositoryUpdate,
|
||||
ctx: &mut ModelContext<Repository>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
|
||||
{
|
||||
let mut st = self.state.lock().unwrap();
|
||||
merge_repository_updates(&mut st.pending, update);
|
||||
st.version = st.version.wrapping_add(1);
|
||||
|
||||
// Start a single background flusher if it's not already running.
|
||||
if st.flush_handle.is_none() {
|
||||
let inner = Arc::clone(&self.inner);
|
||||
let state = Arc::clone(&self.state);
|
||||
let wait = self.debounce;
|
||||
|
||||
st.flush_handle = Some(ctx.spawn(
|
||||
async move {
|
||||
// Loop until we observe a quiet period (version stable for `wait`).
|
||||
loop {
|
||||
// Capture current version, then wait.
|
||||
let start_version = {
|
||||
let st = state.lock().unwrap();
|
||||
st.version
|
||||
};
|
||||
warpui::r#async::Timer::after(wait).await;
|
||||
|
||||
// If version unchanged, we're quiet; flush pending and exit loop.
|
||||
let maybe_merged = {
|
||||
// Yield before flushing to check if the current flush is cancelled.
|
||||
futures_lite::future::yield_now().await;
|
||||
|
||||
let mut st = state.lock().unwrap();
|
||||
if st.version == start_version {
|
||||
st.flush_handle = None;
|
||||
Some(std::mem::take(&mut st.pending))
|
||||
} else {
|
||||
// Newer update arrived during the wait; try waiting again.
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(merged) = maybe_merged {
|
||||
break (inner, merged);
|
||||
}
|
||||
}
|
||||
},
|
||||
|repo_model, (inner, merged), repo_ctx| {
|
||||
if merged.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut inner) = inner.lock() {
|
||||
let fut = inner.on_files_updated(repo_model, &merged, repo_ctx);
|
||||
// Drive the subscriber's async update to completion.
|
||||
repo_ctx.spawn(fut, |_, _, _| {});
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Box::pin(ready(()))
|
||||
}
|
||||
|
||||
fn on_unsubscribe(&mut self, _ctx: &mut ModelContext<Repository>) {
|
||||
let Ok(mut st) = self.state.lock() else {
|
||||
return;
|
||||
};
|
||||
if let Some(handle) = st.flush_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use warp_core::HostId;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
|
||||
/// Identifies a repository across local and remote environments.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
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),
|
||||
}
|
||||
|
||||
impl RepositoryIdentifier {
|
||||
/// Convenience constructor for a local repository identifier.
|
||||
pub fn local(path: StandardizedPath) -> Self {
|
||||
Self::Local(path)
|
||||
}
|
||||
|
||||
/// Convenience constructor that creates a `Local` identifier from a
|
||||
/// `std::path::Path`. Returns `None` if the path is not absolute or
|
||||
/// contains non-UTF-8 characters.
|
||||
pub fn try_local(path: &Path) -> Option<Self> {
|
||||
StandardizedPath::try_from_local(path).ok().map(Self::Local)
|
||||
}
|
||||
|
||||
/// Returns the local path if this is a `Local` variant.
|
||||
pub fn local_path(&self) -> Option<&StandardizedPath> {
|
||||
match self {
|
||||
Self::Local(path) => Some(path),
|
||||
Self::Remote(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the local path as a `PathBuf` if this is a `Local` variant
|
||||
/// and the encoding matches the current OS.
|
||||
pub fn local_path_buf(&self) -> Option<PathBuf> {
|
||||
match self {
|
||||
Self::Local(path) => path.to_local_path(),
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RemoteRepositoryIdentifier> for RepositoryIdentifier {
|
||||
fn from(id: RemoteRepositoryIdentifier) -> Self {
|
||||
Self::Remote(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use serde_json::{json, Value};
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
use warp_core::{
|
||||
register_telemetry_event,
|
||||
telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc},
|
||||
};
|
||||
|
||||
#[derive(Clone, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter))]
|
||||
pub enum RepoMetadataTelemetryEvent {
|
||||
BuildTreeFailed { error: String },
|
||||
}
|
||||
|
||||
impl TelemetryEvent for RepoMetadataTelemetryEvent {
|
||||
fn name(&self) -> &'static str {
|
||||
RepoMetadataTelemetryEventDiscriminants::from(self).name()
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
RepoMetadataTelemetryEventDiscriminants::from(self).description()
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
RepoMetadataTelemetryEventDiscriminants::from(self).enablement_state()
|
||||
}
|
||||
|
||||
fn payload(&self) -> Option<Value> {
|
||||
match self {
|
||||
Self::BuildTreeFailed { error } => Some(json!({
|
||||
"error": error
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_ugc(&self) -> bool {
|
||||
match self {
|
||||
Self::BuildTreeFailed { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
|
||||
warp_core::telemetry::enum_events::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryEventDesc for RepoMetadataTelemetryEventDiscriminants {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::BuildTreeFailed => "RepoMetadata.BuildTree.Failed",
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::BuildTreeFailed => "Failed to build file tree for repo metadata",
|
||||
}
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
match self {
|
||||
Self::BuildTreeFailed => EnablementState::Always,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register_telemetry_event!(RepoMetadataTelemetryEvent);
|
||||
@@ -0,0 +1,787 @@
|
||||
use std::{
|
||||
collections::{hash_map::Entry, HashMap, HashSet, VecDeque},
|
||||
future::Future,
|
||||
hash::{Hash, Hasher},
|
||||
path::{Path, PathBuf},
|
||||
pin::Pin,
|
||||
};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use futures::{future::OptionFuture, FutureExt as _};
|
||||
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle};
|
||||
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
|
||||
use crate::{repository::SubscriberId, RepoMetadataError, Repository};
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
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,
|
||||
};
|
||||
/// Duration between filesystem watch events in milliseconds
|
||||
const FILESYSTEM_WATCHER_DEBOUNCE_MILLI_SECS: u64 = 500;
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_TASKS: usize = 2;
|
||||
|
||||
/// A global singleton model that records and watches directory changes.
|
||||
/// It is important to note that the directory here doesn't equal to a git repository. To
|
||||
/// reference a whether a path is a git repository or not, check `DetectedRepositories`.
|
||||
pub struct DirectoryWatcher {
|
||||
/// Map of known directories to watch.
|
||||
directories: HashMap<StandardizedPath, ModelHandle<Repository>>,
|
||||
|
||||
/// The filesystem watcher for monitoring changes.
|
||||
#[cfg(feature = "local_fs")]
|
||||
watcher: Option<ModelHandle<BulkFilesystemWatcher>>,
|
||||
|
||||
/// Handle to the internal processing queue model that orders scan & update tasks.
|
||||
processing_queue: ModelHandle<TaskQueue>,
|
||||
}
|
||||
|
||||
impl DirectoryWatcher {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
let fs_watcher = ctx.add_model(|ctx| {
|
||||
BulkFilesystemWatcher::new(
|
||||
std::time::Duration::from_millis(FILESYSTEM_WATCHER_DEBOUNCE_MILLI_SECS),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
ctx.subscribe_to_model(&fs_watcher, Self::handle_watcher_event);
|
||||
} else {
|
||||
// Silence an unused parameter warning.
|
||||
let _ = ctx;
|
||||
}
|
||||
}
|
||||
|
||||
let processing_queue = ctx.add_model(TaskQueue::new);
|
||||
ctx.subscribe_to_model(&processing_queue, Self::handle_queue_event);
|
||||
|
||||
Self {
|
||||
directories: Default::default(),
|
||||
#[cfg(feature = "local_fs")]
|
||||
watcher: Some(fs_watcher),
|
||||
processing_queue,
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only constructor that uses a stub filesystem watcher with no background thread,
|
||||
/// preventing thread leaks in tests.
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub fn new_for_testing(ctx: &mut ModelContext<Self>) -> Self {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
let fs_watcher = ctx.add_model(|_ctx| BulkFilesystemWatcher::new_for_test());
|
||||
ctx.subscribe_to_model(&fs_watcher, Self::handle_watcher_event);
|
||||
} else {
|
||||
let _ = ctx;
|
||||
}
|
||||
}
|
||||
|
||||
let processing_queue = ctx.add_model(TaskQueue::new);
|
||||
ctx.subscribe_to_model(&processing_queue, Self::handle_queue_event);
|
||||
|
||||
Self {
|
||||
directories: Default::default(),
|
||||
#[cfg(feature = "local_fs")]
|
||||
watcher: Some(fs_watcher),
|
||||
processing_queue,
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a path, return the watched directory that contains it.
|
||||
pub fn get_watched_directory_for_path(&self, path: &Path) -> Option<ModelHandle<Repository>> {
|
||||
let standardized = StandardizedPath::from_local_canonicalized(path).ok()?;
|
||||
self.find_containing_directory(&standardized)
|
||||
}
|
||||
|
||||
/// Find the watched directory that contains the given path, if any.
|
||||
fn find_containing_directory(
|
||||
&self,
|
||||
path: &StandardizedPath,
|
||||
) -> Option<ModelHandle<Repository>> {
|
||||
let mut current = Some(path.clone());
|
||||
while let Some(ancestor) = current {
|
||||
if let Some(repo) = self.directories.get(&ancestor) {
|
||||
return Some(repo.clone());
|
||||
}
|
||||
current = ancestor.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a directory is registered for the given path.
|
||||
pub fn is_directory_watched(&self, path: &StandardizedPath) -> bool {
|
||||
self.directories.contains_key(path)
|
||||
}
|
||||
|
||||
/// Find repositories affected by a git directory change using three-tier
|
||||
/// 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
|
||||
/// `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
|
||||
/// repo whose working tree directly contains `.git` (main repo).
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn find_repos_for_git_event(
|
||||
&self,
|
||||
git_path: &Path,
|
||||
ctx: &ModelContext<Self>,
|
||||
) -> Vec<ModelHandle<Repository>> {
|
||||
let mut affected: Vec<ModelHandle<Repository>> = Vec::new();
|
||||
|
||||
// Tier 1: route to the single linked worktree.
|
||||
if let Some(wt_dir) = extract_worktree_git_dir(git_path) {
|
||||
log::debug!(
|
||||
"[GIT_EVENT_ROUTING] tier=worktree-specific path={}",
|
||||
git_path.display()
|
||||
);
|
||||
let wt_std = StandardizedPath::from_local_canonicalized(wt_dir.as_path()).ok();
|
||||
for repo_handle in self.directories.values() {
|
||||
if let Some(ext) = repo_handle.as_ref(ctx).external_git_directory() {
|
||||
if wt_std.as_ref() == Some(ext) && !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
|
||||
// common_git_dir() is a prefix of the event path.
|
||||
log::debug!(
|
||||
"[GIT_EVENT_ROUTING] tier=shared-ref path={}",
|
||||
git_path.display()
|
||||
);
|
||||
let standardized = StandardizedPath::from_local_canonicalized(git_path).ok();
|
||||
if let Some(ref std_path) = standardized {
|
||||
if let Some(repo) = self.find_containing_directory(std_path) {
|
||||
if !affected.iter().any(|r| r == &repo) {
|
||||
affected.push(repo);
|
||||
}
|
||||
}
|
||||
for repo_handle in self.directories.values() {
|
||||
let common = repo_handle.read(ctx, |repo, _| repo.common_git_dir());
|
||||
if let Ok(common_std) = StandardizedPath::try_from_local(common.as_path()) {
|
||||
if std_path.starts_with(&common_std)
|
||||
&& !affected.iter().any(|r| r == repo_handle)
|
||||
{
|
||||
affected.push(repo_handle.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Tier 3: 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={}",
|
||||
git_path.display()
|
||||
);
|
||||
let standardized = StandardizedPath::from_local_canonicalized(git_path).ok();
|
||||
if let Some(ref std_path) = standardized {
|
||||
if let Some(repo) = self.find_containing_directory(std_path) {
|
||||
affected.push(repo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let repo_roots: Vec<_> = affected
|
||||
.iter()
|
||||
.map(|r| r.as_ref(ctx).root_dir().to_string())
|
||||
.collect();
|
||||
log::debug!(
|
||||
"[GIT_EVENT_ROUTING] path={} affected_repos=[{}]",
|
||||
git_path.display(),
|
||||
repo_roots.join(", ")
|
||||
);
|
||||
|
||||
affected
|
||||
}
|
||||
|
||||
/// Register a known code directory. If the directory already exists, it will not be re-registered.
|
||||
pub fn add_directory(
|
||||
&mut self,
|
||||
repository_path: StandardizedPath,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<ModelHandle<Repository>, RepoMetadataError> {
|
||||
self.add_directory_with_git_dir(repository_path, None, ctx)
|
||||
}
|
||||
|
||||
/// Register a known code directory with optional external git directory.
|
||||
/// If the directory already exists, it will not be re-registered.
|
||||
pub fn add_directory_with_git_dir(
|
||||
&mut self,
|
||||
repository_path: StandardizedPath,
|
||||
external_git_directory: Option<StandardizedPath>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<ModelHandle<Repository>, RepoMetadataError> {
|
||||
let local_path = repository_path
|
||||
.to_local_path()
|
||||
.ok_or_else(|| RepoMetadataError::PathEncodingMismatch(repository_path.clone()))?;
|
||||
|
||||
if !local_path.exists() {
|
||||
return Err(RepoMetadataError::RepoNotFound(repository_path.to_string()));
|
||||
}
|
||||
|
||||
if !local_path.is_dir() {
|
||||
return Err(RepoMetadataError::InvalidPath(
|
||||
"Repository path must be a directory".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if there's an existing registration to reuse.
|
||||
let entry = self.directories.entry(repository_path);
|
||||
if let Entry::Occupied(ref entry) = entry {
|
||||
log::debug!("Using already-registered repository");
|
||||
return Ok(entry.get().clone());
|
||||
}
|
||||
|
||||
// The repository is either not registered, or has expired.
|
||||
let queue_handle = self.processing_queue.clone();
|
||||
let repository_handle = ctx.add_model(|_ctx| {
|
||||
Repository::new(
|
||||
entry.key().clone(),
|
||||
external_git_directory.clone(),
|
||||
queue_handle,
|
||||
)
|
||||
});
|
||||
entry.insert_entry(repository_handle.clone());
|
||||
|
||||
Ok(repository_handle)
|
||||
}
|
||||
|
||||
/// Starts watching multiple directories for filesystem changes.
|
||||
///
|
||||
/// The returned future resolves once all directories are registered.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn start_watching_directories(
|
||||
&mut self,
|
||||
directory_paths: Vec<StandardizedPath>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> impl Future<Output = Result<(), RepoMetadataError>> {
|
||||
let futures: Vec<_> = directory_paths
|
||||
.into_iter()
|
||||
.map(|path| self.start_watching_directory(&path, ctx))
|
||||
.collect();
|
||||
|
||||
async move {
|
||||
for future in futures {
|
||||
future.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// Starts watching a directory for filesystem changes.
|
||||
///
|
||||
/// The returned future resolves once the directory is registered. Filesystem changes before
|
||||
/// this may not be observed.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn start_watching_directory(
|
||||
&mut self,
|
||||
directory_path: &StandardizedPath,
|
||||
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() {
|
||||
watcher.update(ctx, |watcher, _ctx| {
|
||||
use crate::entry::should_ignore_git_path;
|
||||
use notify_debouncer_full::notify::{RecursiveMode, WatchFilter};
|
||||
use std::sync::Arc;
|
||||
|
||||
let watch_filter = WatchFilter::with_filter(Arc::new(move |watch_path| {
|
||||
!should_ignore_git_path(watch_path)
|
||||
}));
|
||||
|
||||
Some(watcher.register_path(&local_path, watch_filter, RecursiveMode::Recursive))
|
||||
})
|
||||
} else {
|
||||
log::warn!("Cannot watch non-local path: {directory_path}");
|
||||
None
|
||||
}
|
||||
} else {
|
||||
log::warn!("No watcher available");
|
||||
None
|
||||
};
|
||||
|
||||
let path_display = directory_path.to_string();
|
||||
OptionFuture::from(registration_future).map(move |result| match result {
|
||||
Some(Ok(())) => {
|
||||
log::debug!("Started watching {path_display}");
|
||||
Ok(())
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::debug!("Failed to start watching {path_display}: {e:#}");
|
||||
Err(e.into())
|
||||
}
|
||||
None => Ok(()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stops watching a directory for filesystem changes.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn stop_watching_directory(
|
||||
&mut self,
|
||||
directory_path: &StandardizedPath,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> impl Future<Output = Result<(), anyhow::Error>> {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
let local_path = directory_path.to_local_path();
|
||||
let unregistration_future = if let Some(ref watcher) = self.watcher {
|
||||
if let Some(local_path) = local_path {
|
||||
watcher.update(ctx, |watcher, _ctx| {
|
||||
Some(watcher.unregister_path(&local_path))
|
||||
})
|
||||
} else {
|
||||
log::warn!("Cannot unwatch non-local path: {directory_path}");
|
||||
None
|
||||
}
|
||||
} else {
|
||||
log::warn!("No watcher available");
|
||||
None
|
||||
};
|
||||
|
||||
let path_display = directory_path.to_string();
|
||||
OptionFuture::from(unregistration_future).map(move |result| match result {
|
||||
Some(Ok(())) => {
|
||||
log::debug!("Stopped watching {path_display}");
|
||||
Ok(())
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("Failed to stop watching {path_display}: {e:#}");
|
||||
Err(e)
|
||||
}
|
||||
None => Ok(()),
|
||||
})
|
||||
} else {
|
||||
async { Ok(()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles events from the internal task queue.
|
||||
fn handle_queue_event(&mut self, event: &TaskQueueEvent, ctx: &mut ModelContext<Self>) {
|
||||
let &TaskQueueEvent::TaskEnqueued = event;
|
||||
self.processing_queue.update(ctx, |queue, ctx| {
|
||||
queue.advance(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
#[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;
|
||||
}
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
/// Handles filesystem watcher events.
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn handle_watcher_event(
|
||||
&mut self,
|
||||
event: &BulkFilesystemWatcherEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Group changes by repository
|
||||
let mut repo_updates: HashMap<ModelHandle<Repository>, RepositoryUpdate> = HashMap::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()
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process moved files
|
||||
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);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Ok(standardized) =
|
||||
StandardizedPath::from_local_canonicalized(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));
|
||||
let from_is_ignored =
|
||||
repo_handle.read(ctx, |repo, _| repo.check_gitignore_status(from_path));
|
||||
let to_target = TargetFile::new(to_path.to_path_buf(), to_is_ignored);
|
||||
let from_target = TargetFile::new(from_path.to_path_buf(), from_is_ignored);
|
||||
let repo_update = repo_updates.entry(repo_handle).or_default();
|
||||
repo_update.moved.insert(to_target, from_target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.processing_queue.update(ctx, |queue, ctx| {
|
||||
for (repo_handle, repo_update) in repo_updates {
|
||||
let subscriber_ids = repo_handle.read(ctx, |repo, _| repo.get_subscriber_ids());
|
||||
for subscriber_id in subscriber_ids {
|
||||
queue.enqueue_incremental_update(
|
||||
repo_handle.downgrade(),
|
||||
subscriber_id,
|
||||
repo_update.clone(),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DirectoryWatcher {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for DirectoryWatcher {}
|
||||
|
||||
/// Represents a file in a repository with its gitignore status.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TargetFile {
|
||||
pub path: PathBuf,
|
||||
pub is_ignored: bool,
|
||||
}
|
||||
|
||||
impl TargetFile {
|
||||
pub fn new(path: PathBuf, is_ignored: bool) -> Self {
|
||||
Self { path, is_ignored }
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for TargetFile {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.path.hash(state);
|
||||
self.is_ignored.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for TargetFile {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.path == other.path && self.is_ignored == other.is_ignored
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for TargetFile {}
|
||||
|
||||
impl PartialOrd for TargetFile {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for TargetFile {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.path
|
||||
.cmp(&other.path)
|
||||
.then_with(|| self.is_ignored.cmp(&other.is_ignored))
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes detected in a repository.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RepositoryUpdate {
|
||||
/// Files that were added.
|
||||
pub added: HashSet<TargetFile>,
|
||||
|
||||
/// Files whose contents were modified.
|
||||
pub modified: HashSet<TargetFile>,
|
||||
|
||||
/// Files that were deleted.
|
||||
pub deleted: HashSet<TargetFile>,
|
||||
|
||||
/// Files that were moved (to_path, from_path).
|
||||
pub moved: HashMap<TargetFile, TargetFile>,
|
||||
|
||||
/// Whether a commit-related file changed (`.git/HEAD` or `.git/refs/heads/*`).
|
||||
pub commit_updated: bool,
|
||||
|
||||
/// Whether the git index lock file was created or removed (`.git/index.lock`).
|
||||
pub index_lock_detected: bool,
|
||||
}
|
||||
|
||||
impl RepositoryUpdate {
|
||||
/// Returns true if this update contains no changes.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.added.is_empty()
|
||||
&& self.modified.is_empty()
|
||||
&& self.deleted.is_empty()
|
||||
&& self.moved.is_empty()
|
||||
&& !self.commit_updated
|
||||
&& !self.index_lock_detected
|
||||
}
|
||||
|
||||
/// Iterator over all created and modified files.
|
||||
///
|
||||
/// Most consumers don't care about the added-vs-modified distinction.
|
||||
pub fn added_or_modified(&self) -> impl Iterator<Item = &TargetFile> {
|
||||
self.added.iter().chain(self.modified.iter())
|
||||
}
|
||||
|
||||
/// Owned iterator over all created and modified files.
|
||||
pub fn into_added_or_modified(self) -> impl Iterator<Item = TargetFile> {
|
||||
self.added.into_iter().chain(self.modified)
|
||||
}
|
||||
|
||||
pub fn contains_added_or_modified(&self, file: &TargetFile) -> bool {
|
||||
self.added.contains(file) || self.modified.contains(file)
|
||||
}
|
||||
}
|
||||
|
||||
/// An asynchronous task in a watched repository.
|
||||
#[derive(Clone)]
|
||||
enum Task {
|
||||
/// Perform an initial (or re-)scan for the given subscriber on the repository.
|
||||
Scan {
|
||||
repository: WeakModelHandle<Repository>,
|
||||
subscriber_id: SubscriberId,
|
||||
},
|
||||
#[cfg(feature = "local_fs")]
|
||||
/// Deliver an incremental update (filesystem changes) to a specific repository subscriber.
|
||||
Update {
|
||||
repository: WeakModelHandle<Repository>,
|
||||
subscriber_id: SubscriberId,
|
||||
update: RepositoryUpdate,
|
||||
},
|
||||
}
|
||||
|
||||
impl Task {
|
||||
fn execute(
|
||||
self,
|
||||
ctx: &mut ModelContext<TaskQueue>,
|
||||
) -> Option<Pin<Box<dyn Future<Output = ()> + Send>>> {
|
||||
match self {
|
||||
Task::Scan {
|
||||
repository,
|
||||
subscriber_id,
|
||||
} => {
|
||||
if let Some(repository) = repository.upgrade(ctx) {
|
||||
repository.update(ctx, |repository, ctx| {
|
||||
repository.scan_subscriber(subscriber_id, ctx)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "local_fs")]
|
||||
Task::Update {
|
||||
repository,
|
||||
subscriber_id,
|
||||
update,
|
||||
} => {
|
||||
if let Some(repository) = repository.upgrade(ctx) {
|
||||
repository.update(ctx, |repository, ctx| {
|
||||
repository.notify_subscriber(subscriber_id, &update, ctx)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum TaskQueueEvent {
|
||||
TaskEnqueued,
|
||||
}
|
||||
|
||||
/// Lightweight task queue model for watched repositories. The [`RepositoryWatcher`] model uses this
|
||||
/// to limit throughput for CPU- or disk-intensive update operations.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TaskQueue {
|
||||
/// Tasks which have not yet been executed.
|
||||
pending_tasks: VecDeque<Task>,
|
||||
active_tasks: usize,
|
||||
}
|
||||
|
||||
impl TaskQueue {
|
||||
fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Enqueue a new task.
|
||||
fn enqueue(&mut self, task: Task, ctx: &mut ModelContext<Self>) {
|
||||
self.pending_tasks.push_back(task);
|
||||
|
||||
// Notify the watcher that a new task has been enqueued. This prevents circular model
|
||||
// updates by ensuring new tasks aren't immediately dequeued.
|
||||
ctx.emit(TaskQueueEvent::TaskEnqueued);
|
||||
}
|
||||
|
||||
/// Advance through the queue by executing new tasks, up to the concurrency limit.
|
||||
fn advance(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
while self.active_tasks < MAX_CONCURRENT_TASKS {
|
||||
let Some(task) = self.pending_tasks.pop_front() else {
|
||||
break;
|
||||
};
|
||||
|
||||
if let Some(future) = task.execute(ctx) {
|
||||
self.active_tasks += 1;
|
||||
ctx.spawn(future, move |me, _, ctx| {
|
||||
me.handle_task_completion(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_task_completion(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.active_tasks -= 1;
|
||||
self.advance(ctx);
|
||||
}
|
||||
|
||||
/// Convenience helpers for enqueuing specific task kinds.
|
||||
pub(crate) fn enqueue_scan(
|
||||
&mut self,
|
||||
repository: WeakModelHandle<Repository>,
|
||||
subscriber_id: SubscriberId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.enqueue(
|
||||
Task::Scan {
|
||||
repository,
|
||||
subscriber_id,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) fn enqueue_incremental_update(
|
||||
&mut self,
|
||||
repository: WeakModelHandle<Repository>,
|
||||
subscriber_id: SubscriberId,
|
||||
update: RepositoryUpdate,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.enqueue(
|
||||
Task::Update {
|
||||
repository,
|
||||
subscriber_id,
|
||||
update,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for TaskQueue {
|
||||
type Event = TaskQueueEvent;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "watcher_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,513 @@
|
||||
use std::fs;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::repositories::stub_git_repository;
|
||||
use crate::repository::RepositorySubscriber;
|
||||
use crate::watcher::{DirectoryWatcher, TaskQueue};
|
||||
use crate::{CanonicalizedPath, RepoMetadataError, Repository, RepositoryUpdate};
|
||||
use futures::channel::mpsc;
|
||||
use futures::StreamExt as _;
|
||||
use virtual_fs::{Stub, VirtualFS};
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
use warpui::r#async::Timer;
|
||||
use warpui::{App, ModelContext, ModelHandle};
|
||||
|
||||
#[test]
|
||||
fn test_add_repository_success() {
|
||||
VirtualFS::test("add_repo_success", |dirs, mut vfs| {
|
||||
stub_git_repository(&mut vfs, "git_repo");
|
||||
|
||||
let git_repo = dirs.tests().join("git_repo");
|
||||
let canonical_path = StandardizedPath::from_local_canonicalized(&git_repo).unwrap();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let watcher_handle = app.add_model(DirectoryWatcher::new);
|
||||
|
||||
let result = watcher_handle.update(&mut app, |watcher, ctx| {
|
||||
watcher.add_directory(canonical_path.clone(), ctx)
|
||||
});
|
||||
|
||||
assert!(result.is_ok());
|
||||
let repo_handle = result.unwrap();
|
||||
|
||||
// Verify the repository was registered
|
||||
let repo_path = repo_handle.read(&app, |repo, _ctx| repo.root_dir().clone());
|
||||
assert_eq!(repo_path.to_local_path().as_deref().unwrap(), git_repo);
|
||||
|
||||
// Verify it's in the watcher's registry
|
||||
watcher_handle.read(&app, |watcher, _ctx| {
|
||||
assert!(watcher.is_directory_watched(&canonical_path));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_repository_non_existent() {
|
||||
VirtualFS::test("add_repo_nonexistent", |dirs, _vfs| {
|
||||
let non_existent = dirs.tests().join("non_existent");
|
||||
// Don't create the directory
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let watcher_handle = app.add_model(DirectoryWatcher::new);
|
||||
|
||||
let result = watcher_handle.update(&mut app, |watcher, ctx| {
|
||||
// This will fail because we can't canonicalize a non-existent path
|
||||
let canonical_result = CanonicalizedPath::try_from(&non_existent);
|
||||
match canonical_result {
|
||||
Ok(canonical_path) => watcher.add_directory(canonical_path.into(), ctx),
|
||||
Err(_) => Err(RepoMetadataError::RepoNotFound(
|
||||
"Path does not exist".to_string(),
|
||||
)),
|
||||
}
|
||||
});
|
||||
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
RepoMetadataError::RepoNotFound(_) => {} // Expected
|
||||
_ => panic!("Expected RepoNotFound error"),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A test subscriber that uses async channels to signal scan completion.
|
||||
struct TestSubscriber {
|
||||
scan_completed_tx: mpsc::UnboundedSender<()>,
|
||||
update_completed_tx: mpsc::UnboundedSender<RepositoryUpdate>,
|
||||
active_tasks: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl TestSubscriber {
|
||||
fn new(
|
||||
scan_completed_tx: mpsc::UnboundedSender<()>,
|
||||
update_completed_tx: mpsc::UnboundedSender<RepositoryUpdate>,
|
||||
active_scans: Arc<AtomicUsize>,
|
||||
) -> Self {
|
||||
Self {
|
||||
scan_completed_tx,
|
||||
update_completed_tx,
|
||||
active_tasks: active_scans,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RepositorySubscriber for TestSubscriber {
|
||||
fn on_scan(
|
||||
&mut self,
|
||||
_repository: &Repository,
|
||||
_ctx: &mut ModelContext<Repository>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
|
||||
let scan_completed_tx = self.scan_completed_tx.clone();
|
||||
let active_tasks = self.active_tasks.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
let prev = active_tasks.fetch_add(1, Ordering::SeqCst);
|
||||
// Ensure we never exceed the allowed concurrency.
|
||||
assert!(
|
||||
prev < super::MAX_CONCURRENT_TASKS,
|
||||
"Started a scan while already at concurrency limit"
|
||||
);
|
||||
|
||||
// Simulate scan work.
|
||||
Timer::after(Duration::from_millis(10)).await;
|
||||
|
||||
active_tasks.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = scan_completed_tx.unbounded_send(());
|
||||
})
|
||||
}
|
||||
|
||||
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_completed_tx = self.update_completed_tx.clone();
|
||||
let active_tasks = self.active_tasks.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
let prev = active_tasks.fetch_add(1, Ordering::SeqCst);
|
||||
// Ensure we never exceed the allowed concurrency.
|
||||
assert!(
|
||||
prev < super::MAX_CONCURRENT_TASKS,
|
||||
"Started an incremental update while already at concurrency limit"
|
||||
);
|
||||
|
||||
// Simulate update work.
|
||||
Timer::after(Duration::from_millis(10)).await;
|
||||
|
||||
active_tasks.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = update_completed_tx.unbounded_send(update);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "flaky test: CODE-1748"]
|
||||
fn test_task_queue_processes_all_tasks() {
|
||||
VirtualFS::test("task_queue_processes_all_tasks", |dirs, mut vfs| {
|
||||
stub_git_repository(&mut vfs, "repo1");
|
||||
stub_git_repository(&mut vfs, "repo2");
|
||||
|
||||
let repo1 = dirs.tests().join("repo1");
|
||||
let repo2 = dirs.tests().join("repo2");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let watcher_handle = app.add_singleton_model(DirectoryWatcher::new);
|
||||
|
||||
let repo1_handle = watcher_handle
|
||||
.update(&mut app, |watcher, ctx| {
|
||||
watcher.add_directory(
|
||||
StandardizedPath::from_local_canonicalized(&repo1).unwrap(),
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let repo2_handle = watcher_handle
|
||||
.update(&mut app, |watcher, ctx| {
|
||||
watcher.add_directory(
|
||||
StandardizedPath::from_local_canonicalized(&repo2).unwrap(),
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let active_tasks = Arc::new(AtomicUsize::new(0));
|
||||
let (scan_tx, mut scan_rx) = mpsc::unbounded::<()>();
|
||||
let (update_tx, _) = mpsc::unbounded::<RepositoryUpdate>();
|
||||
|
||||
// Spawn 10 watchers per repository
|
||||
for _ in 0..10 {
|
||||
let subscriber1 =
|
||||
TestSubscriber::new(scan_tx.clone(), update_tx.clone(), active_tasks.clone());
|
||||
let subscriber2 =
|
||||
TestSubscriber::new(scan_tx.clone(), update_tx.clone(), active_tasks.clone());
|
||||
|
||||
let start = repo1_handle.update(&mut app, |repo, ctx| {
|
||||
repo.start_watching(Box::new(subscriber1), ctx)
|
||||
});
|
||||
start
|
||||
.registration_future
|
||||
.await
|
||||
.expect("Failed to add subscriber");
|
||||
|
||||
let start = repo2_handle.update(&mut app, |repo, ctx| {
|
||||
repo.start_watching(Box::new(subscriber2), ctx)
|
||||
});
|
||||
start
|
||||
.registration_future
|
||||
.await
|
||||
.expect("Failed to add subscriber");
|
||||
}
|
||||
|
||||
// Wait for all scans to complete by consuming all messages
|
||||
for _ in 0..20 {
|
||||
scan_rx.next().await.expect("Scan should complete");
|
||||
}
|
||||
|
||||
// Ensure no active scans remain
|
||||
assert_eq!(
|
||||
active_tasks.load(Ordering::SeqCst),
|
||||
0,
|
||||
"Scans not completed"
|
||||
);
|
||||
|
||||
let queue = watcher_handle.read(&app, |watcher, _| watcher.processing_queue.clone());
|
||||
wait_for_queue_complete(queue, &mut app).await;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_queue_handles_nonexistent_subscriber() {
|
||||
VirtualFS::test("scan_queue_nonexistent_subscriber", |dirs, mut vfs| {
|
||||
// Create a git repository
|
||||
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);
|
||||
|
||||
// Create repository
|
||||
let repo_handle = watcher_handle
|
||||
.update(&mut app, |watcher, ctx| {
|
||||
watcher.add_directory(
|
||||
StandardizedPath::from_local_canonicalized(&repo_path).unwrap(),
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Manually enqueue a scan request for a non-existent subscriber
|
||||
watcher_handle.update(&mut app, |watcher, ctx| {
|
||||
watcher.processing_queue.update(ctx, |queue, ctx| {
|
||||
queue.enqueue_scan(repo_handle.clone().downgrade(), 999, ctx);
|
||||
// Non-existent subscriber ID
|
||||
});
|
||||
});
|
||||
|
||||
// Add a real subscriber to test that the queue continues processing
|
||||
let (tx, mut rx) = mpsc::unbounded::<()>();
|
||||
let (update_tx, _) = mpsc::unbounded::<RepositoryUpdate>();
|
||||
let active_scans = Arc::new(AtomicUsize::new(0));
|
||||
let subscriber = TestSubscriber::new(tx, update_tx, active_scans.clone());
|
||||
|
||||
std::mem::drop(repo_handle.update(&mut app, |repo, ctx| {
|
||||
repo.start_watching(Box::new(subscriber), ctx)
|
||||
}));
|
||||
|
||||
// Wait for processing to complete
|
||||
rx.next().await.unwrap();
|
||||
|
||||
// Verify the real subscriber's scan completed despite the invalid scan request
|
||||
assert_eq!(
|
||||
active_scans.load(Ordering::SeqCst),
|
||||
0,
|
||||
"Real subscriber scan should complete"
|
||||
);
|
||||
|
||||
let queue = watcher_handle.read(&app, |watcher, _| watcher.processing_queue.clone());
|
||||
wait_for_queue_complete(queue, &mut app).await;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "CODE-1071 - test is flaky and needs to be fixed"]
|
||||
fn test_file_updates_delivered() {
|
||||
env_logger::init();
|
||||
|
||||
VirtualFS::test("file_updates_delivered", |dirs, mut vfs| {
|
||||
stub_git_repository(&mut vfs, "test_repo");
|
||||
vfs.with_files(vec![
|
||||
Stub::FileWithContent("test_repo/file1.txt", "content1"),
|
||||
Stub::FileWithContent("test_repo/file2.txt", "content2"),
|
||||
]);
|
||||
|
||||
let repo_path = dirs.tests().join("test_repo");
|
||||
App::test((), |mut app| async move {
|
||||
let watcher_handle = app.add_singleton_model(DirectoryWatcher::new);
|
||||
|
||||
let repo_handle = watcher_handle
|
||||
.update(&mut app, |watcher, ctx| {
|
||||
watcher.add_directory(
|
||||
StandardizedPath::from_local_canonicalized(&repo_path).unwrap(),
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let task_count = Arc::new(AtomicUsize::new(0));
|
||||
let (scan_tx, mut scan_rx) = mpsc::unbounded::<()>();
|
||||
let (update_tx, mut update_rx) = mpsc::unbounded::<RepositoryUpdate>();
|
||||
|
||||
let subscriber_count = 3;
|
||||
for _ in 0..subscriber_count {
|
||||
let subscriber =
|
||||
TestSubscriber::new(scan_tx.clone(), update_tx.clone(), task_count.clone());
|
||||
let start = repo_handle.update(&mut app, |repo, ctx| {
|
||||
repo.start_watching(Box::new(subscriber), ctx)
|
||||
});
|
||||
start
|
||||
.registration_future
|
||||
.await
|
||||
.expect("Failed to add subscriber");
|
||||
}
|
||||
|
||||
// Wait for all initial scans to complete.
|
||||
for _ in 0..subscriber_count {
|
||||
scan_rx.next().await.expect("Scan should complete");
|
||||
}
|
||||
|
||||
let file1_path = repo_path.join("file1.txt");
|
||||
let file2_path = repo_path.join("file2.txt");
|
||||
fs::write(&file1_path, "new content").expect("Updating file 1 failed");
|
||||
fs::write(&file2_path, "new content").expect("Updating file 2 failed");
|
||||
|
||||
// Ensure that all subscribers receive the updates.
|
||||
for _ in 0..subscriber_count {
|
||||
let update = update_rx.next().await.expect("Update should complete");
|
||||
let mut changed: Vec<_> =
|
||||
update.into_added_or_modified().map(|tf| tf.path).collect();
|
||||
changed.sort();
|
||||
assert_eq!(changed, vec![file1_path.clone(), file2_path.clone()]);
|
||||
}
|
||||
|
||||
let queue = watcher_handle.read(&app, |watcher, _| watcher.processing_queue.clone());
|
||||
wait_for_queue_complete(queue, &mut app).await;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Wait for a task queue to have finished all its tasks. This will not directly advance the queue, but allows the UI framework to mark async tasks as complete.
|
||||
async fn wait_for_queue_complete(queue: ModelHandle<TaskQueue>, app: &mut App) {
|
||||
const DELAY: Duration = Duration::from_millis(5);
|
||||
const ATTEMPTS: u32 = 10;
|
||||
|
||||
for _ in 0..ATTEMPTS {
|
||||
let complete = queue.update(app, |queue, _| {
|
||||
queue.active_tasks == 0 && queue.pending_tasks.is_empty()
|
||||
});
|
||||
if complete {
|
||||
return;
|
||||
}
|
||||
|
||||
Timer::after(DELAY).await;
|
||||
}
|
||||
|
||||
queue.read(app, |queue, _| {
|
||||
panic!(
|
||||
"Queue not empty after {:?}: {} pending tasks, {} active",
|
||||
DELAY * ATTEMPTS,
|
||||
queue.pending_tasks.len(),
|
||||
queue.active_tasks
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_git_internal_path() {
|
||||
use crate::entry::is_git_internal_path;
|
||||
use std::path::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")));
|
||||
assert!(is_git_internal_path(Path::new(
|
||||
"/repo/.git/refs/heads/main"
|
||||
)));
|
||||
assert!(is_git_internal_path(Path::new("/repo/.git/config")));
|
||||
assert!(is_git_internal_path(Path::new("/repo/.git/objects/abc123")));
|
||||
assert!(is_git_internal_path(Path::new("/repo/.git")));
|
||||
|
||||
#[cfg(windows)]
|
||||
assert!(is_git_internal_path(Path::new(r"C:\repo\.git\HEAD")));
|
||||
|
||||
// Non-git files should not be detected
|
||||
assert!(!is_git_internal_path(Path::new("/repo/src/main.rs")));
|
||||
assert!(!is_git_internal_path(Path::new("/repo/README.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "flaky test: CODE-1492"]
|
||||
fn test_commit_related_files_excluded_from_update_lists() {
|
||||
VirtualFS::test("commit_files_excluded", |dirs, mut vfs| {
|
||||
log::info!("Start setting up test vfs");
|
||||
stub_git_repository(&mut vfs, "test_repo");
|
||||
|
||||
// Create .git/refs/heads directory structure
|
||||
vfs.mkdir("test_repo/.git/refs/heads");
|
||||
vfs.with_files(vec![
|
||||
Stub::FileWithContent("test_repo/.git/HEAD", "ref: refs/heads/main"),
|
||||
Stub::FileWithContent("test_repo/.git/refs/heads/main", "abc123def456"),
|
||||
Stub::FileWithContent("test_repo/regular_file.txt", "content"),
|
||||
]);
|
||||
|
||||
let repo_path = dirs.tests().join("test_repo");
|
||||
App::test((), |mut app| async move {
|
||||
let watcher_handle = app.add_singleton_model(DirectoryWatcher::new);
|
||||
|
||||
let repo_handle = watcher_handle
|
||||
.update(&mut app, |watcher, ctx| {
|
||||
watcher.add_directory(
|
||||
StandardizedPath::from_local_canonicalized(&repo_path).unwrap(),
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let (scan_tx, mut scan_rx) = mpsc::unbounded::<()>();
|
||||
let (update_tx, mut update_rx) = mpsc::unbounded::<RepositoryUpdate>();
|
||||
let active_scans = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let subscriber =
|
||||
TestSubscriber::new(scan_tx.clone(), update_tx.clone(), active_scans.clone());
|
||||
|
||||
log::info!("Start setting up watcher");
|
||||
let start = repo_handle.update(&mut app, |repo, ctx| {
|
||||
repo.start_watching(Box::new(subscriber), ctx)
|
||||
});
|
||||
start
|
||||
.registration_future
|
||||
.await
|
||||
.expect("Failed to add subscriber");
|
||||
log::info!("Finished setting up watcher");
|
||||
|
||||
// Wait for initial scan to complete
|
||||
scan_rx.next().await.expect("Scan should complete");
|
||||
log::info!("Initial scan completed");
|
||||
|
||||
// Update both a regular file and a commit-related file
|
||||
let regular_file_path = repo_path.join("regular_file.txt");
|
||||
let head_file_path = repo_path.join(".git/HEAD");
|
||||
let branch_file_path = repo_path.join(".git/refs/heads/main");
|
||||
|
||||
std::fs::write(®ular_file_path, "new content")
|
||||
.expect("Updating regular file failed");
|
||||
std::fs::write(&head_file_path, "ref: refs/heads/feature")
|
||||
.expect("Updating HEAD failed");
|
||||
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 {
|
||||
futures::select! {
|
||||
update = futures::FutureExt::fuse(update_rx.next()) => {
|
||||
match update {
|
||||
Some(update) => {
|
||||
log::info!("Received update");
|
||||
break update;
|
||||
}
|
||||
None => {
|
||||
panic!("Update channel closed unexpectedly");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = futures::FutureExt::fuse(Timer::after(Duration::from_secs(5))) => {
|
||||
log::warn!("Waiting for update timed out after 5s, retrying...");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Verify that commit_updated is true
|
||||
assert!(
|
||||
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)),
|
||||
"Regular file should be in added/modified list"
|
||||
);
|
||||
assert!(
|
||||
!update.contains_added_or_modified(&TargetFile::new(head_file_path.clone(), false)),
|
||||
"Git HEAD file should NOT be in added/modified list"
|
||||
);
|
||||
assert!(
|
||||
!update
|
||||
.contains_added_or_modified(&TargetFile::new(branch_file_path.clone(), false)),
|
||||
"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(),
|
||||
"Update should not be empty when commit_updated is true"
|
||||
);
|
||||
|
||||
let queue = watcher_handle.read(&app, |watcher, _| watcher.processing_queue.clone());
|
||||
wait_for_queue_complete(queue, &mut app).await;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Unified repository metadata model.
|
||||
//!
|
||||
//! [`RepoMetadataModel`] is the singleton entry point for all repository metadata
|
||||
//! queries. It holds handles to [`LocalRepoMetadataModel`] and
|
||||
//! [`RemoteRepoMetadataModel`] and dispatches operations based on
|
||||
//! [`RepositoryIdentifier`].
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::path::Path;
|
||||
|
||||
use warp_core::HostId;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
use warpui::{AppContext, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::file_tree_store::FileTreeState;
|
||||
use crate::file_tree_update::RepoMetadataUpdate;
|
||||
use crate::local_model::{
|
||||
GetContentsArgs, IndexedRepoState, LocalRepoMetadataModel, RepoContent, RepositoryMetadataEvent,
|
||||
};
|
||||
use crate::remote_model::{RemoteRepoMetadataModel, RemoteRepositoryMetadataEvent};
|
||||
use crate::repository_identifier::{RemoteRepositoryIdentifier, RepositoryIdentifier};
|
||||
use crate::RepoMetadataError;
|
||||
|
||||
/// Unified events emitted by the [`RepoMetadataModel`] wrapper.
|
||||
///
|
||||
/// These are mapped from the sub-model events into a common enum keyed by
|
||||
/// [`RepositoryIdentifier`].
|
||||
#[derive(Debug)]
|
||||
pub enum RepoMetadataEvent {
|
||||
/// A repository was added or updated.
|
||||
RepositoryUpdated { id: RepositoryIdentifier },
|
||||
/// A repository was removed.
|
||||
RepositoryRemoved { id: RepositoryIdentifier },
|
||||
/// File trees for repositories were updated.
|
||||
FileTreeUpdated { ids: Vec<RepositoryIdentifier> },
|
||||
/// A file tree entry was updated.
|
||||
FileTreeEntryUpdated { id: RepositoryIdentifier },
|
||||
/// Updating a repository failed.
|
||||
UpdatingRepositoryFailed { id: RepositoryIdentifier },
|
||||
/// An incremental file tree update is ready to be sent to the remote
|
||||
/// client. Only emitted when the local model has
|
||||
/// `emit_incremental_updates` enabled.
|
||||
IncrementalUpdateReady { update: RepoMetadataUpdate },
|
||||
}
|
||||
|
||||
/// Singleton wrapper that provides a unified API over local and remote
|
||||
/// repository metadata models.
|
||||
///
|
||||
/// All consumers should interact with this type rather than accessing the
|
||||
/// sub-models directly. The wrapper does **not** expose `.local()` or
|
||||
/// `.remote()` accessors — encapsulation ensures consumers are decoupled
|
||||
/// from the local/remote split.
|
||||
pub struct RepoMetadataModel {
|
||||
local: ModelHandle<LocalRepoMetadataModel>,
|
||||
remote: ModelHandle<RemoteRepoMetadataModel>,
|
||||
}
|
||||
|
||||
impl RepoMetadataModel {
|
||||
/// Creates a new `RepoMetadataModel`, instantiating both sub-models and
|
||||
/// subscribing to their events for forwarding.
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let local = ctx.add_model(LocalRepoMetadataModel::new);
|
||||
let remote = ctx.add_model(RemoteRepoMetadataModel::new);
|
||||
|
||||
ctx.subscribe_to_model(&local, Self::forward_local_event);
|
||||
ctx.subscribe_to_model(&remote, Self::forward_remote_event);
|
||||
|
||||
Self { local, remote }
|
||||
}
|
||||
|
||||
/// Creates a new `RepoMetadataModel` with incremental update emission
|
||||
/// enabled on the local sub-model. Used by the remote server.
|
||||
pub fn new_with_incremental_updates(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let local = ctx.add_model(|ctx| {
|
||||
let mut model = LocalRepoMetadataModel::new(ctx);
|
||||
model.set_emit_incremental_updates(true);
|
||||
model
|
||||
});
|
||||
let remote = ctx.add_model(RemoteRepoMetadataModel::new);
|
||||
|
||||
ctx.subscribe_to_model(&local, Self::forward_local_event);
|
||||
ctx.subscribe_to_model(&remote, Self::forward_remote_event);
|
||||
|
||||
Self { local, remote }
|
||||
}
|
||||
|
||||
// ── Event forwarding ─────────────────────────────────────────────
|
||||
|
||||
fn forward_local_event(
|
||||
&mut self,
|
||||
event: &RepositoryMetadataEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let unified = match event {
|
||||
RepositoryMetadataEvent::RepositoryUpdated { path } => {
|
||||
RepoMetadataEvent::RepositoryUpdated {
|
||||
id: RepositoryIdentifier::local(path.clone()),
|
||||
}
|
||||
}
|
||||
RepositoryMetadataEvent::RepositoryRemoved { path } => {
|
||||
RepoMetadataEvent::RepositoryRemoved {
|
||||
id: RepositoryIdentifier::local(path.clone()),
|
||||
}
|
||||
}
|
||||
RepositoryMetadataEvent::FileTreeUpdated { paths } => {
|
||||
RepoMetadataEvent::FileTreeUpdated {
|
||||
ids: paths
|
||||
.iter()
|
||||
.map(|p| RepositoryIdentifier::local(p.clone()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
RepositoryMetadataEvent::FileTreeEntryUpdated { path } => {
|
||||
RepoMetadataEvent::FileTreeEntryUpdated {
|
||||
id: RepositoryIdentifier::local(path.clone()),
|
||||
}
|
||||
}
|
||||
RepositoryMetadataEvent::UpdatingRepositoryFailed { path } => {
|
||||
RepoMetadataEvent::UpdatingRepositoryFailed {
|
||||
id: RepositoryIdentifier::local(path.clone()),
|
||||
}
|
||||
}
|
||||
RepositoryMetadataEvent::IncrementalUpdateReady { update } => {
|
||||
RepoMetadataEvent::IncrementalUpdateReady {
|
||||
update: update.clone(),
|
||||
}
|
||||
}
|
||||
};
|
||||
ctx.emit(unified);
|
||||
}
|
||||
|
||||
fn forward_remote_event(
|
||||
&mut self,
|
||||
event: &RemoteRepositoryMetadataEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let unified = match event {
|
||||
RemoteRepositoryMetadataEvent::RepositoryUpdated { id } => {
|
||||
RepoMetadataEvent::RepositoryUpdated {
|
||||
id: RepositoryIdentifier::Remote(id.clone()),
|
||||
}
|
||||
}
|
||||
RemoteRepositoryMetadataEvent::RepositoryRemoved { id } => {
|
||||
RepoMetadataEvent::RepositoryRemoved {
|
||||
id: RepositoryIdentifier::Remote(id.clone()),
|
||||
}
|
||||
}
|
||||
RemoteRepositoryMetadataEvent::FileTreeUpdated { ids } => {
|
||||
RepoMetadataEvent::FileTreeUpdated {
|
||||
ids: ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(RepositoryIdentifier::Remote)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
RemoteRepositoryMetadataEvent::FileTreeEntryUpdated { id } => {
|
||||
RepoMetadataEvent::FileTreeEntryUpdated {
|
||||
id: RepositoryIdentifier::Remote(id.clone()),
|
||||
}
|
||||
}
|
||||
};
|
||||
ctx.emit(unified);
|
||||
}
|
||||
|
||||
// ── Unified query API ────────────────────────────────────────────
|
||||
|
||||
/// Returns the [`FileTreeState`] for a repository identified by `id`.
|
||||
pub fn get_repository<'a>(
|
||||
&self,
|
||||
id: &RepositoryIdentifier,
|
||||
ctx: &'a AppContext,
|
||||
) -> Option<&'a FileTreeState> {
|
||||
match id {
|
||||
RepositoryIdentifier::Local(path) => self.local.as_ref(ctx).get_repository(path),
|
||||
RepositoryIdentifier::Remote(remote_id) => {
|
||||
self.remote.as_ref(ctx).get_repository(remote_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the given repository is indexed.
|
||||
pub fn has_repository(&self, id: &RepositoryIdentifier, ctx: &AppContext) -> bool {
|
||||
match id {
|
||||
RepositoryIdentifier::Local(path) => self.local.as_ref(ctx).has_repository(path),
|
||||
RepositoryIdentifier::Remote(remote_id) => {
|
||||
self.remote.as_ref(ctx).has_repository(remote_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current [`IndexedRepoState`] for a repository.
|
||||
pub fn repository_state<'a>(
|
||||
&self,
|
||||
id: &RepositoryIdentifier,
|
||||
ctx: &'a AppContext,
|
||||
) -> Option<&'a IndexedRepoState> {
|
||||
match id {
|
||||
RepositoryIdentifier::Local(path) => self.local.as_ref(ctx).repository_state(path),
|
||||
RepositoryIdentifier::Remote(remote_id) => {
|
||||
self.remote.as_ref(ctx).repository_state(remote_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns repository contents for the specified repository.
|
||||
pub fn get_repo_contents<'a>(
|
||||
&self,
|
||||
id: &RepositoryIdentifier,
|
||||
args: GetContentsArgs,
|
||||
ctx: &'a AppContext,
|
||||
) -> Option<Vec<RepoContent<'a>>> {
|
||||
match id {
|
||||
RepositoryIdentifier::Local(path) => {
|
||||
self.local.as_ref(ctx).get_repo_contents(path, args)
|
||||
}
|
||||
RepositoryIdentifier::Remote(remote_id) => {
|
||||
self.remote.as_ref(ctx).get_repo_contents(remote_id, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the repository root that contains the given local path.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn find_repository_for_path(
|
||||
&self,
|
||||
path: &Path,
|
||||
ctx: &AppContext,
|
||||
) -> Option<StandardizedPath> {
|
||||
self.local.as_ref(ctx).find_repository_for_path(path)
|
||||
}
|
||||
|
||||
// ── Local-specific operations ────────────────────────────────────
|
||||
// These delegate to the local sub-model. Remote equivalents will be
|
||||
// added once the remote client ↔ server sync layer is in place.
|
||||
|
||||
/// Indexes a local repository from the given repository handle.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn index_directory(
|
||||
&self,
|
||||
repository: ModelHandle<crate::repository::Repository>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<(), RepoMetadataError> {
|
||||
self.local
|
||||
.update(ctx, |local, ctx| local.index_directory(repository, ctx))
|
||||
}
|
||||
|
||||
/// Lazily indexes a local standalone path with only the first level of children.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn index_lazy_loaded_path(
|
||||
&self,
|
||||
path: &StandardizedPath,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<(), RepoMetadataError> {
|
||||
let path = path.clone();
|
||||
self.local
|
||||
.update(ctx, |local, ctx| local.index_lazy_loaded_path(&path, ctx))
|
||||
}
|
||||
|
||||
/// Loads a specific directory inside an already-tracked local tree.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn load_directory(
|
||||
&self,
|
||||
repo_root: &StandardizedPath,
|
||||
dir_path: &StandardizedPath,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<(), RepoMetadataError> {
|
||||
let repo_root = repo_root.clone();
|
||||
let dir_path = dir_path.clone();
|
||||
self.local.update(ctx, |local, ctx| {
|
||||
local.load_directory(&repo_root, &dir_path, ctx)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
let path = path.clone();
|
||||
self.local
|
||||
.update(ctx, |local, ctx| local.remove_lazy_loaded_path(&path, ctx));
|
||||
}
|
||||
|
||||
// ── Remote-specific operations ─────────────────────────────────
|
||||
// These delegate to the remote sub-model and are called by the
|
||||
// RemoteServerManager event subscription in the app layer.
|
||||
|
||||
/// Inserts or replaces a remote repository from a snapshot push event.
|
||||
pub fn insert_remote_snapshot(
|
||||
&self,
|
||||
host_id: HostId,
|
||||
update: &RepoMetadataUpdate,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.remote.update(ctx, |remote, ctx| {
|
||||
remote.insert_from_snapshot(host_id, update, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Applies an incremental remote repo metadata update.
|
||||
pub fn apply_remote_incremental_update(
|
||||
&self,
|
||||
host_id: &HostId,
|
||||
update: &RepoMetadataUpdate,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let host_id = host_id.clone();
|
||||
self.remote.update(ctx, |remote, ctx| {
|
||||
remote.apply_incremental_update(&host_id, update, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Removes all remote repositories for the given host (e.g. on disconnect).
|
||||
pub fn remove_remote_repositories_for_host(
|
||||
&self,
|
||||
host_id: &HostId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let host_id = host_id.clone();
|
||||
self.remote.update(ctx, |remote, ctx| {
|
||||
remote.remove_repositories_for_host(&host_id, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Removes a repository (local or remote) from tracking.
|
||||
pub fn remove_repository(
|
||||
&self,
|
||||
id: &RepositoryIdentifier,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<(), RepoMetadataError> {
|
||||
match id {
|
||||
RepositoryIdentifier::Local(path) => {
|
||||
let path = path.clone();
|
||||
self.local
|
||||
.update(ctx, |local, ctx| local.remove_repository(&path, ctx))
|
||||
}
|
||||
RepositoryIdentifier::Remote(remote_id) => {
|
||||
let remote_id = remote_id.clone();
|
||||
self.remote
|
||||
.update(ctx, |remote, ctx| remote.remove_repository(&remote_id, ctx));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all tracked remote repository identifiers.
|
||||
pub fn remote_repository_ids<'a>(
|
||||
&self,
|
||||
ctx: &'a AppContext,
|
||||
) -> impl Iterator<Item = &'a RemoteRepositoryIdentifier> {
|
||||
self.remote.as_ref(ctx).remote_repository_ids()
|
||||
}
|
||||
|
||||
/// Returns whether the given local path is tracked as a lazily-loaded standalone path.
|
||||
pub fn is_lazy_loaded_path(&self, path: &StandardizedPath, ctx: &AppContext) -> bool {
|
||||
self.local.as_ref(ctx).is_lazy_loaded_path(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for RepoMetadataModel {
|
||||
type Event = RepoMetadataEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for RepoMetadataModel {}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
impl RepoMetadataModel {
|
||||
/// Inserts repository state directly into the local sub-model for testing.
|
||||
pub fn insert_test_state(
|
||||
&self,
|
||||
repo_path: StandardizedPath,
|
||||
state: FileTreeState,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.local.update(ctx, |local, _ctx| {
|
||||
local.insert_test_state(repo_path, state);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user