Skip to main content

clankerdiff_watch/
repository_watcher.rs

1use crate::{
2    error::WatchError,
3    file_watcher::{FileWatcher, NotifyFileWatcher, worktree_walk},
4    filter::should_refresh,
5};
6use clankerdiff_core::DiffScope;
7use clankerdiff_git::{GitError, GitRepository, RepositorySnapshot};
8use ignore::WalkBuilder;
9use std::{sync::Arc, time::Duration};
10use tokio::{
11    sync::{mpsc, oneshot, watch},
12    task::{JoinHandle, spawn_blocking},
13};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct WatchOptions {
17    pub debounce: Duration,
18}
19
20impl Default for WatchOptions {
21    fn default() -> Self {
22        Self {
23            debounce: Duration::from_millis(150),
24        }
25    }
26}
27
28#[derive(Debug)]
29pub enum RepositoryRequest {
30    Refresh {
31        result_tx: oneshot::Sender<Result<(), Arc<GitError>>>,
32    },
33    RefreshScope {
34        scope: DiffScope,
35        result_tx: oneshot::Sender<Result<(), Arc<GitError>>>,
36    },
37    Shutdown,
38    Subscribe {
39        scope: DiffScope,
40        result_tx: oneshot::Sender<Result<watch::Receiver<RepositoryState>, Arc<GitError>>>,
41    },
42    SetScope {
43        scope: DiffScope,
44        result_tx: oneshot::Sender<Result<(), Arc<GitError>>>,
45    },
46}
47
48/// The retained repository state: the last successful snapshot and the health of
49/// the most recent load. A failed load keeps the previous snapshot in place.
50#[derive(Debug, Clone)]
51pub struct RepositoryState {
52    pub snapshot: Arc<RepositorySnapshot>,
53    pub error: Option<Arc<GitError>>,
54}
55
56impl RepositoryState {
57    /// The most recent load failure as a display message, if any.
58    #[must_use]
59    pub fn error_message(&self) -> Option<String> {
60        self.error.as_ref().map(ToString::to_string)
61    }
62
63    /// Folds one load result in; returns whether anything observable changed.
64    fn apply(&mut self, result: Result<RepositorySnapshot, Arc<GitError>>) -> bool {
65        match result {
66            Ok(snapshot) => {
67                let recovered = self.error.take().is_some();
68                if *self.snapshot == snapshot {
69                    return recovered;
70                }
71                self.snapshot = Arc::new(snapshot);
72                true
73            }
74            Err(error) => {
75                if self.error_message() == Some(error.to_string()) {
76                    return false;
77                }
78                self.error = Some(error);
79                true
80            }
81        }
82    }
83}
84
85#[derive(Debug, thiserror::Error)]
86pub enum RepositoryHandleError {
87    #[error(transparent)]
88    Git(#[from] Arc<GitError>),
89    #[error("the repository watcher has stopped")]
90    Stopped,
91}
92
93#[derive(Debug, Clone)]
94pub struct RepositoryHandle {
95    request_tx: mpsc::Sender<RepositoryRequest>,
96}
97
98impl RepositoryHandle {
99    pub async fn subscribe(
100        &self,
101        scope: DiffScope,
102    ) -> Result<watch::Receiver<RepositoryState>, RepositoryHandleError> {
103        let (result_tx, result_rx) = oneshot::channel();
104        self.request_tx
105            .send(RepositoryRequest::Subscribe { scope, result_tx })
106            .await
107            .map_err(|_| RepositoryHandleError::Stopped)?;
108        result_rx
109            .await
110            .map_err(|_| RepositoryHandleError::Stopped)?
111            .map_err(RepositoryHandleError::Git)
112    }
113
114    pub async fn refresh(&self, scope: DiffScope) -> Result<(), RepositoryHandleError> {
115        let (result_tx, result_rx) = oneshot::channel();
116        self.request_tx
117            .send(RepositoryRequest::RefreshScope { scope, result_tx })
118            .await
119            .map_err(|_| RepositoryHandleError::Stopped)?;
120        result_rx
121            .await
122            .map_err(|_| RepositoryHandleError::Stopped)?
123            .map_err(RepositoryHandleError::Git)
124    }
125}
126
127#[derive(Debug)]
128pub struct RepositoryWatcher {
129    pub request_tx: mpsc::Sender<RepositoryRequest>,
130    pub state_rx: watch::Receiver<RepositoryState>,
131    task: JoinHandle<()>,
132}
133
134impl RepositoryWatcher {
135    pub async fn spawn(
136        repository: GitRepository,
137        scope: DiffScope,
138        options: WatchOptions,
139    ) -> Result<Self, WatchError> {
140        let watcher = Self::create_file_watcher(&repository, options.debounce).await?;
141        let snapshot = repository.snapshot_with_sources(scope).await?;
142        let (request_tx, request_rx) = mpsc::channel(64);
143        let (state_tx, state_rx) = watch::channel(RepositoryState {
144            snapshot: Arc::new(snapshot),
145            error: None,
146        });
147        let actor = RepositoryActor {
148            repository,
149            watcher,
150            scope,
151            request_rx,
152            state_tx,
153            subscriptions: [None, None, None],
154        };
155        Ok(Self {
156            request_tx,
157            state_rx,
158            task: tokio::spawn(actor.run()),
159        })
160    }
161
162    #[must_use]
163    pub fn handle(&self) -> RepositoryHandle {
164        RepositoryHandle {
165            request_tx: self.request_tx.clone(),
166        }
167    }
168
169    pub async fn shutdown(mut self) -> Result<(), WatchError> {
170        self.request_tx
171            .send(RepositoryRequest::Shutdown)
172            .await
173            .map_err(|_| WatchError::Stopped)?;
174        (&mut self.task).await.map_err(|_| WatchError::Stopped)
175    }
176
177    async fn create_file_watcher(
178        repository: &GitRepository,
179        debounce: Duration,
180    ) -> Result<NotifyFileWatcher, WatchError> {
181        let directories = repository.metadata_directories().await?;
182        let mut worktree = worktree_walk(repository.root());
183        worktree.filter_entry(|entry| entry.file_name() != ".git");
184        let mut walks = vec![worktree];
185        if let Some((first, rest)) = directories.split_first() {
186            let mut metadata = WalkBuilder::new(first);
187            for directory in rest {
188                metadata.add(directory);
189            }
190            metadata.standard_filters(false).filter_entry(|entry| {
191                entry.depth() != 1 || matches!(entry.file_name().to_str(), Some("refs" | "info"))
192            });
193            walks.push(metadata);
194        }
195        let repository = repository.clone();
196        let watcher = spawn_blocking(move || {
197            NotifyFileWatcher::with_walks(walks, debounce, move |paths| {
198                let repository = repository.clone();
199                let directories = directories.clone();
200                async move { should_refresh(&repository, &directories, paths).await }
201            })
202        })
203        .await
204        .map_err(|_| WatchError::Stopped)??;
205        Ok(watcher)
206    }
207}
208
209impl Drop for RepositoryWatcher {
210    fn drop(&mut self) {
211        self.task.abort();
212    }
213}
214
215struct RepositoryActor {
216    repository: GitRepository,
217    watcher: NotifyFileWatcher,
218    scope: DiffScope,
219    request_rx: mpsc::Receiver<RepositoryRequest>,
220    state_tx: watch::Sender<RepositoryState>,
221    subscriptions: [Option<watch::Sender<RepositoryState>>; 3],
222}
223
224impl RepositoryActor {
225    async fn run(mut self) {
226        loop {
227            tokio::select! {
228                biased;
229                request = self.request_rx.recv() => match request {
230                    Some(RepositoryRequest::SetScope { scope, result_tx }) => {
231                        self.scope = scope;
232                        let _ = result_tx.send(self.refresh_scope(scope, true).await);
233                    }
234                    Some(RepositoryRequest::Refresh { result_tx }) => {
235                        let _ = result_tx.send(self.refresh_scope(self.scope, true).await);
236                    }
237                    Some(RepositoryRequest::RefreshScope { scope, result_tx }) => {
238                        let _ = result_tx.send(self.refresh_scope(scope, true).await);
239                    }
240                    Some(RepositoryRequest::Subscribe { scope, result_tx }) => {
241                        let _ = result_tx.send(self.subscribe(scope).await);
242                    }
243                    Some(RepositoryRequest::Shutdown) | None => return,
244                },
245                event = self.watcher.recv() => match event {
246                    Some(()) => self.refresh_active().await,
247                    None => return,
248                },
249            }
250        }
251    }
252
253    async fn subscribe(
254        &mut self,
255        scope: DiffScope,
256    ) -> Result<watch::Receiver<RepositoryState>, Arc<GitError>> {
257        if scope == self.scope {
258            return Ok(self.state_tx.subscribe());
259        }
260        let index = scope_index(scope);
261        if let Some(state) = &self.subscriptions[index] {
262            return Ok(state.subscribe());
263        }
264        let snapshot = self
265            .repository
266            .snapshot_with_sources(scope)
267            .await
268            .map_err(Arc::new)?;
269        let (state, receiver) = watch::channel(RepositoryState {
270            snapshot: Arc::new(snapshot),
271            error: None,
272        });
273        self.subscriptions[index] = Some(state);
274        Ok(receiver)
275    }
276
277    async fn refresh_active(&mut self) {
278        let scopes = [DiffScope::Unstaged, DiffScope::Staged, DiffScope::Both];
279        for scope in scopes {
280            let subscribed = self.subscriptions[scope_index(scope)]
281                .as_ref()
282                .is_some_and(|state| state.receiver_count() > 0);
283            if scope == self.scope || subscribed {
284                let _ = self.refresh_scope(scope, false).await;
285            } else {
286                self.subscriptions[scope_index(scope)] = None;
287            }
288        }
289    }
290
291    async fn refresh_scope(&mut self, scope: DiffScope, retry: bool) -> Result<(), Arc<GitError>> {
292        let result = if retry {
293            self.repository.snapshot_with_sources(scope).await
294        } else {
295            match self.repository.try_snapshot_with_sources(scope).await {
296                Err(GitError::UnstableSnapshot) => {
297                    self.repository
298                        .snapshot(scope)
299                        .await
300                        .map(|document| RepositorySnapshot {
301                            scope,
302                            document: Arc::new(document),
303                        })
304                }
305                result => result,
306            }
307        }
308        .map_err(Arc::new);
309        let outcome = result.as_ref().map(|_| ()).map_err(Arc::clone);
310        if scope == self.scope {
311            self.state_tx
312                .send_if_modified(|state| state.apply(clone_result(&result)));
313        }
314        if let Some(state) = &self.subscriptions[scope_index(scope)] {
315            state.send_if_modified(|current| current.apply(clone_result(&result)));
316        }
317        outcome
318    }
319}
320
321fn clone_result(
322    result: &Result<RepositorySnapshot, Arc<GitError>>,
323) -> Result<RepositorySnapshot, Arc<GitError>> {
324    result.clone()
325}
326
327const fn scope_index(scope: DiffScope) -> usize {
328    match scope {
329        DiffScope::Unstaged => 0,
330        DiffScope::Staged => 1,
331        DiffScope::Both => 2,
332    }
333}