Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+618
View File
@@ -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;
+415
View File
@@ -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
);
}
+402
View File
@@ -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(&current_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")
));
}
+180
View File
@@ -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
+217
View File
@@ -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));
}
}
+307
View File
@@ -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(&current).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(
&regular_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(&regular_dir).unwrap();
repo_handle.read(&app, |watcher, _ctx| {
assert!(!watcher.repository_roots.contains(&regular_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);
});
});
}
+509
View File
@@ -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)
}
}
+66
View File
@@ -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);
+787
View File
@@ -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;
+513
View File
@@ -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(&regular_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;
});
});
}
+378
View File
@@ -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);
});
}
}