Files
galaxy/crates/repo_metadata/src/repository.rs
T

510 lines
19 KiB
Rust

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 galaxy_util::standardized_path::StandardizedPath;
use galaxyui::r#async::{BoxFuture, SpawnedFutureHandle};
#[cfg(feature = "local_fs")]
use galaxyui::SingletonEntity;
use galaxyui::{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
};
galaxyui::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();
}
}
}