1use super::auto_push::push_once;
18use super::auto_push_single_flight::PushGit;
19use super::config_snapshot::read_config_snapshot;
20use super::lfs_pre_push::uses_lfs;
21use super::maintenance_run::{AutoMaintenanceOutcome, run_auto_maintenance_with};
22use super::push_failure_notice::{clear_push_failures, record_push_failure};
23use super::service_review::{BUDGET, options_for, review_item, review_variables};
24use super::service_review_key::ReviewKey;
25use super::standards_review_run::ReviewOptions;
26use super::transaction_git::GitContext;
27use super::work_item::{DONE, FAILED, WORK_DIRECTORY, WorkItem, WorkKind, claim_next_of, requeue_claimed, requeue_expired, settle};
28use std::path::{Path, PathBuf};
29use std::time::SystemTime;
30
31pub const SERVICE_KINDS: [WorkKind; 3] = [WorkKind::Push, WorkKind::Maintenance, WorkKind::Review];
33
34pub const MAX_TRIES: u32 = 3;
36
37pub type Wake = (PathBuf, Option<ReviewKey>);
39
40#[derive(Clone, Debug, Eq, PartialEq)]
42pub enum Verdict {
43 Done,
45 Failed(String),
47}
48
49pub trait WorkHandlers {
51 fn push(&self, items: &[WorkItem]) -> Verdict;
53 fn maintenance(&self, item: &WorkItem) -> Verdict;
55 fn review(&self, item: &WorkItem) -> Verdict;
57}
58
59pub fn work_directory(git_dir: &Path) -> PathBuf {
61 return git_dir.join(WORK_DIRECTORY);
62}
63
64pub fn is_acceptable(item: &WorkItem, git_dir: &Path) -> bool {
75 return Path::new(item.repository.as_str()).is_absolute()
76 && Path::new(item.repository.as_str()).is_dir()
77 && Path::new(item.git_dir.as_str()) == git_dir
78 && item.branch_ref.starts_with("refs/")
79 && !item.branch_ref.contains(['\0', '\n', ' '])
80 && item.oid.len() >= 40
81 && item.oid.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
82}
83
84fn settle_all(work_dir: &Path, claimed: &[super::work_item::Claimed], state: &str) {
85 for item in claimed {
86 if let Err(reason) = settle(work_dir, item, state) {
87 eprintln!("cli-git-service: {reason}");
88 }
89 }
90}
91
92pub fn drain(git_dir: &Path, handlers: &dyn WorkHandlers, now: SystemTime) {
104 let _requeued = requeue_claimed(work_directory(git_dir).as_path(), MAX_TRIES);
108 drain_pending(git_dir, handlers, now);
109}
110
111pub fn drain_common_records(git_dir: &Path, handlers: &dyn WorkHandlers, now: SystemTime) {
124 let Some(common) = common_directory_of(git_dir) else { return };
125 if common == git_dir || !work_directory(common.as_path()).is_dir() {
126 return;
127 }
128 let _requeued = requeue_expired(work_directory(common.as_path()).as_path(), now, SHARED_CLAIM_LEASE, MAX_TRIES);
129 drain_pending(common.as_path(), handlers, now);
130}
131
132fn common_directory_of(git_dir: &Path) -> Option<PathBuf> {
134 let text: String = std::fs::read_to_string(git_dir.join("commondir")).ok()?;
135 let named: &Path = Path::new(text.trim_end_matches(['\n', '\r']));
136 if named.as_os_str().is_empty() {
137 return None;
138 }
139 let mut resolved: PathBuf = PathBuf::new();
142 for part in git_dir.join(named).components() {
143 match part {
144 std::path::Component::ParentDir => {
145 if !resolved.pop() {
146 return None;
147 }
148 }
149 std::path::Component::CurDir => {}
150 other => resolved.push(other.as_os_str()),
151 }
152 }
153 return Some(resolved);
154}
155
156fn drain_pending(git_dir: &Path, handlers: &dyn WorkHandlers, now: SystemTime) {
158 let work_dir: PathBuf = work_directory(git_dir);
159 loop {
160 let mut claimed: Vec<super::work_item::Claimed> = Vec::new();
161 loop {
162 match claim_next_of(work_dir.as_path(), now, &SERVICE_KINDS) {
163 Ok(Some(item)) => claimed.push(item),
164 Ok(None) => break,
165 Err(reason) => {
166 eprintln!("cli-git-service: {reason}");
167 break;
168 }
169 }
170 }
171 if claimed.is_empty() {
172 return;
173 }
174 let (acceptable, refused): (Vec<_>, Vec<_>) = claimed.into_iter().partition(|item| { return is_acceptable(&item.item, git_dir); });
175 settle_all(work_dir.as_path(), &refused, FAILED);
176 let (pushes, rest): (Vec<_>, Vec<_>) = acceptable.into_iter().partition(|item| { return item.item.kind == WorkKind::Push; });
177 let (reviews, upkeep): (Vec<_>, Vec<_>) = rest.into_iter().partition(|item| { return item.item.kind == WorkKind::Review; });
178 let mut groups: Vec<((String, String), Vec<super::work_item::Claimed>)> = Vec::new();
179 for item in pushes {
180 let key = (item.item.repository.clone(), item.item.branch_ref.clone());
181 match groups.iter_mut().find(|(existing, _)| { return *existing == key; }) {
182 Some((_, members)) => members.push(item),
183 None => groups.push((key, vec![item])),
184 }
185 }
186 for (_, members) in groups {
187 let items: Vec<WorkItem> = members.iter().map(|member| { return member.item.clone(); }).collect();
188 match handlers.push(items.as_slice()) {
189 Verdict::Done => {
190 if let Some(newest) = members.last() {
193 clear_push_failures(git_dir, newest.item.branch_ref.as_str());
194 }
195 settle_all(work_dir.as_path(), &members, DONE);
196 }
197 Verdict::Failed(text) => {
198 if let Some(newest) = members.last()
200 && let Err(reason) = record_push_failure(git_dir, newest.item.branch_ref.as_str(), newest.item.oid.as_str(), text.as_str())
201 {
202 eprintln!("cli-git-service: the failed push of {} could not be recorded for the person: {reason}", newest.item.oid);
203 }
204 settle_all(work_dir.as_path(), &members, FAILED);
205 }
206 }
207 }
208 let mut upkept: Vec<String> = Vec::new();
209 for item in upkeep {
210 if !upkept.contains(&item.item.repository) {
211 upkept.push(item.item.repository.clone());
212 if let Verdict::Failed(reason) = handlers.maintenance(&item.item) {
213 eprintln!("cli-git-service: automatic maintenance did not run: {reason}");
214 }
215 }
216 settle_all(work_dir.as_path(), std::slice::from_ref(&item), DONE);
217 }
218 for item in reviews {
220 match handlers.review(&item.item) {
221 Verdict::Done => settle_all(work_dir.as_path(), std::slice::from_ref(&item), DONE),
222 Verdict::Failed(reason) => {
223 eprintln!("cli-git-service: the review of {} failed: {reason}", item.item.oid);
224 settle_all(work_dir.as_path(), std::slice::from_ref(&item), FAILED);
225 }
226 }
227 }
228 }
229}
230
231pub const SHARED_CLAIM_LEASE: std::time::Duration = std::time::Duration::from_secs(900);
233
234pub fn run_round(wakes: Vec<Wake>, handlers: &GitHandlers, now: SystemTime) {
246 let mut directories: Vec<PathBuf> = Vec::new();
247 for (directory, review_key) in wakes {
248 if let Some(key) = review_key {
249 handlers.remember_review_key(directory.as_path(), key);
250 }
251 if !directories.contains(&directory) {
252 directories.push(directory);
253 }
254 }
255 for directory in directories {
256 drain(directory.as_path(), handlers, now);
257 drain_common_records(directory.as_path(), handlers, now);
258 handlers.forget_review_key(directory.as_path());
259 }
260}
261
262pub struct GitHandlers {
264 pub context: GitContext,
266 review_keys: std::sync::Mutex<std::collections::HashMap<PathBuf, ReviewKey>>,
269 #[cfg(test)]
271 review_endpoint: Option<String>,
272}
273
274impl GitHandlers {
276 pub fn new(context: GitContext) -> GitHandlers {
278 return GitHandlers {
279 context,
280 review_keys: std::sync::Mutex::new(std::collections::HashMap::new()),
281 #[cfg(test)]
282 review_endpoint: None,
283 };
284 }
285
286 #[cfg(test)]
288 pub fn with_review_endpoint(mut self, endpoint: &str) -> GitHandlers {
289 self.review_endpoint = Some(endpoint.to_string());
290 return self;
291 }
292
293 pub fn remember_review_key(&self, git_dir: &Path, key: ReviewKey) {
295 self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(git_dir.to_path_buf(), key);
296 }
297
298 pub fn forget_review_key(&self, git_dir: &Path) {
300 self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(git_dir);
301 }
302
303 fn review_key_of(&self, git_dir: &Path) -> Option<ReviewKey> {
304 return self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(git_dir).cloned();
305 }
306
307 fn push_git(&self, item: &WorkItem) -> PushGit {
308 let cwd: PathBuf = PathBuf::from(item.repository.as_str());
309 return PushGit {
310 real_git: self.context.real_git.clone(),
311 lfs: read_config_snapshot(&self.context, cwd.as_path()).as_ref().is_none_or(uses_lfs),
312 cwd,
313 environment: std::env::vars_os().collect(),
314 };
315 }
316}
317
318impl WorkHandlers for GitHandlers {
320 fn push(&self, items: &[WorkItem]) -> Verdict {
321 let Some(newest) = items.last() else { return Verdict::Done };
322 let git: PushGit = self.push_git(newest);
323 let mut output: Vec<u8> = Vec::new();
324 return match push_once(&git, newest.branch_ref.as_str(), newest.oid.as_str(), &mut output) {
325 Ok(result) if result.exit_code == 0 => Verdict::Done,
326 Ok(result) => Verdict::Failed(result.shown),
327 Err(error) => Verdict::Failed(format!("{error}")),
328 };
329 }
330
331 fn maintenance(&self, item: &WorkItem) -> Verdict {
332 return match run_auto_maintenance_with(&self.context, Path::new(item.repository.as_str()), None) {
333 Ok(AutoMaintenanceOutcome::Exited(code)) if code != 0 => Verdict::Failed(format!("it exited with status {code}")),
334 Ok(_) => Verdict::Done,
335 Err(error) => Verdict::Failed(format!("{error:?}")),
336 };
337 }
338
339 fn review(&self, item: &WorkItem) -> Verdict {
340 let key: Option<ReviewKey> = self.review_key_of(Path::new(item.git_dir.as_str()));
341 #[cfg_attr(not(test), expect(unused_mut, reason = "only a test replaces the endpoint"))]
342 let mut options: ReviewOptions = options_for(item, &review_variables(key.as_ref()));
343 #[cfg(test)]
344 if let Some(endpoint) = self.review_endpoint.as_ref() {
345 options.endpoint = endpoint.clone();
346 }
347 return review_item(&self.context, item, options, BUDGET);
348 }
349}
350
351#[cfg(test)]
353#[path = "service_work_tests.rs"]
354mod tests;