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#[derive(Debug, Clone)]
51pub struct RepositoryState {
52 pub snapshot: Arc<RepositorySnapshot>,
53 pub error: Option<Arc<GitError>>,
54}
55
56impl RepositoryState {
57 #[must_use]
59 pub fn error_message(&self) -> Option<String> {
60 self.error.as_ref().map(ToString::to_string)
61 }
62
63 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}