Skip to main content

termesh_git/
worker.rs

1use std::sync::mpsc::{self, Sender};
2use std::thread::JoinHandle;
3
4use termesh_core::{GitEvent, GitRequest};
5
6use crate::GitService;
7
8enum WorkerMessage {
9    Request(GitRequest),
10    Shutdown,
11}
12
13/// Serializes all blocking Git work on one background thread.
14pub struct GitWorker {
15    tx: Sender<WorkerMessage>,
16    handle: Option<JoinHandle<()>>,
17}
18
19impl GitWorker {
20    pub fn spawn<S, F>(mut service: S, sink: F) -> Self
21    where
22        S: GitService,
23        F: Fn(GitEvent) + Send + 'static,
24    {
25        let (tx, rx) = mpsc::channel();
26        let handle = std::thread::Builder::new()
27            .name("termesh-git".into())
28            .spawn(move || {
29                while let Ok(message) = rx.recv() {
30                    let WorkerMessage::Request(request) = message else {
31                        break;
32                    };
33                    run_request(&mut service, request, &sink);
34                }
35            })
36            .expect("spawning the Git worker thread");
37        Self { tx, handle: Some(handle) }
38    }
39
40    /// Queues a Git request. Returns false only after the worker has shut down.
41    pub fn request(&self, request: GitRequest) -> bool {
42        self.tx.send(WorkerMessage::Request(request)).is_ok()
43    }
44}
45
46impl Drop for GitWorker {
47    fn drop(&mut self) {
48        let _ = self.tx.send(WorkerMessage::Shutdown);
49        if let Some(handle) = self.handle.take() {
50            let _ = handle.join();
51        }
52    }
53}
54
55fn run_request<S, F>(service: &mut S, request: GitRequest, sink: &F)
56where
57    S: GitService,
58    F: Fn(GitEvent),
59{
60    let id = match &request {
61        GitRequest::Refresh { id, .. }
62        | GitRequest::Diff { id, .. }
63        | GitRequest::Branches { id, .. }
64        | GitRequest::Execute { id, .. } => *id,
65    };
66    sink(GitEvent::Started { id });
67    match request {
68        GitRequest::Refresh { root, .. } => match service.snapshot(&root) {
69            Ok(snapshot) => sink(GitEvent::SnapshotLoaded { id, snapshot }),
70            Err(failure) => sink(GitEvent::Failed { id, operation_applied: false, failure }),
71        },
72        GitRequest::Diff { root, path, target, .. } => match service.diff(&root, &path, target) {
73            Ok(diff) => sink(GitEvent::DiffLoaded { id, diff }),
74            Err(failure) => sink(GitEvent::Failed { id, operation_applied: false, failure }),
75        },
76        GitRequest::Branches { root, .. } => match service.branches(&root) {
77            Ok(branches) => sink(GitEvent::BranchesLoaded { id, branches }),
78            Err(failure) => sink(GitEvent::Failed { id, operation_applied: false, failure }),
79        },
80        GitRequest::Execute { root, operation, .. } => match service.execute(&root, &operation) {
81            Ok(message) => match service.snapshot(&root) {
82                Ok(snapshot) => {
83                    sink(GitEvent::OperationFinished { id, operation, message, snapshot });
84                }
85                Err(failure) => sink(GitEvent::Failed { id, operation_applied: true, failure }),
86            },
87            Err(failure) => sink(GitEvent::Failed { id, operation_applied: false, failure }),
88        },
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use std::path::Path;
95    use std::sync::{Arc, Mutex};
96
97    use termesh_core::{
98        GitBranch, GitBranchStatus, GitContextDiff, GitDiffTarget, GitEvent, GitFailure,
99        GitFailureKind, GitFileDiff, GitOperation, GitRepositorySnapshot, GitRequest, GitRequestId,
100        GitResult,
101    };
102
103    use crate::{GitService, GitWorker};
104
105    struct RecordingGitService {
106        execute: GitResult<String>,
107        snapshot: GitResult<GitRepositorySnapshot>,
108        calls: Arc<Mutex<Vec<&'static str>>>,
109    }
110
111    impl RecordingGitService {
112        fn scripted(
113            execute: GitResult<String>,
114            snapshot: GitResult<GitRepositorySnapshot>,
115        ) -> (Self, Arc<Mutex<Vec<&'static str>>>) {
116            let calls = Arc::new(Mutex::new(Vec::new()));
117            (Self { execute, snapshot, calls: calls.clone() }, calls)
118        }
119    }
120
121    impl GitService for RecordingGitService {
122        fn snapshot(&mut self, _root: &Path) -> GitResult<GitRepositorySnapshot> {
123            self.calls.lock().unwrap().push("snapshot");
124            self.snapshot.clone()
125        }
126
127        fn diff(
128            &mut self,
129            _root: &Path,
130            _path: &Path,
131            _target: GitDiffTarget,
132        ) -> GitResult<GitFileDiff> {
133            unreachable!()
134        }
135
136        fn branches(&mut self, _root: &Path) -> GitResult<Vec<GitBranch>> {
137            unreachable!()
138        }
139
140        fn execute(&mut self, _root: &Path, _operation: &GitOperation) -> GitResult<String> {
141            self.calls.lock().unwrap().push("execute");
142            self.execute.clone()
143        }
144    }
145
146    fn snapshot() -> GitRepositorySnapshot {
147        GitRepositorySnapshot {
148            repository_root: "/repo".into(),
149            workspace_root: "/repo".into(),
150            branch: GitBranchStatus::default(),
151            files: Vec::new(),
152            context_diff: GitContextDiff::default(),
153        }
154    }
155
156    fn failure(message: &str) -> GitFailure {
157        GitFailure { kind: GitFailureKind::Command, message: message.into() }
158    }
159
160    fn execute_request() -> GitRequest {
161        GitRequest::Execute {
162            id: GitRequestId::new(7),
163            root: "/repo".into(),
164            operation: GitOperation::Commit { message: "message".into() },
165        }
166    }
167
168    #[test]
169    fn execute_refreshes_before_reporting_completion() {
170        let (service, calls) =
171            RecordingGitService::scripted(Ok("committed".into()), Ok(snapshot()));
172        let (tx, rx) = std::sync::mpsc::channel();
173        let worker = GitWorker::spawn(service, move |event| tx.send(event).unwrap());
174        assert!(worker.request(execute_request()));
175        assert!(matches!(rx.recv().unwrap(), GitEvent::Started { .. }));
176        assert!(matches!(rx.recv().unwrap(), GitEvent::OperationFinished { .. }));
177        assert_eq!(*calls.lock().unwrap(), vec!["execute", "snapshot"]);
178    }
179
180    #[test]
181    fn successful_operation_with_failed_refresh_reports_applied_but_stale() {
182        let (service, calls) =
183            RecordingGitService::scripted(Ok("committed".into()), Err(failure("refresh failed")));
184        let (tx, rx) = std::sync::mpsc::channel();
185        let worker = GitWorker::spawn(service, move |event| tx.send(event).unwrap());
186        assert!(worker.request(execute_request()));
187        assert!(matches!(rx.recv().unwrap(), GitEvent::Started { .. }));
188        assert!(matches!(rx.recv().unwrap(), GitEvent::Failed { operation_applied: true, .. }));
189        assert_eq!(*calls.lock().unwrap(), vec!["execute", "snapshot"]);
190    }
191
192    #[test]
193    fn failed_operation_does_not_refresh_and_reports_not_applied() {
194        let (service, calls) =
195            RecordingGitService::scripted(Err(failure("commit failed")), Ok(snapshot()));
196        let (tx, rx) = std::sync::mpsc::channel();
197        let worker = GitWorker::spawn(service, move |event| tx.send(event).unwrap());
198        assert!(worker.request(execute_request()));
199        assert!(matches!(rx.recv().unwrap(), GitEvent::Started { .. }));
200        assert!(matches!(rx.recv().unwrap(), GitEvent::Failed { operation_applied: false, .. }));
201        assert_eq!(*calls.lock().unwrap(), vec!["execute"]);
202    }
203}