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::repo_read_config_text::{last_entry, parse_bool, parse_config_text};
24use super::service_claim_renewal::{ClaimRenewal, HeldClaims, RENEWALS_PER_LEASE};
25use super::service_review::{BUDGET, options_for, review_item, review_variables};
26use super::service_review_key::ReviewKey;
27use super::standards_review_run::ReviewOptions;
28use super::transaction_git::GitContext;
29use super::work_item::{DONE, FAILED, WORK_DIRECTORY, WorkItem, WorkKind, claim_next_of, renew_claim, requeue_claimed, requeue_expired, settle};
30use std::path::{Path, PathBuf};
31use std::time::SystemTime;
32
33pub const SERVICE_KINDS: [WorkKind; 3] = [WorkKind::Push, WorkKind::Maintenance, WorkKind::Review];
35
36pub const MAX_TRIES: u32 = 3;
38
39pub type Wake = (PathBuf, Option<ReviewKey>);
41
42#[derive(Clone, Debug, Eq, PartialEq)]
44pub enum Verdict {
45 Done,
47 Failed(String),
49}
50
51pub trait WorkHandlers {
53 fn push(&self, items: &[WorkItem]) -> Verdict;
55 fn maintenance(&self, item: &WorkItem) -> Verdict;
57 fn review(&self, item: &WorkItem) -> Verdict;
59}
60
61pub fn work_directory(git_dir: &Path) -> PathBuf {
63 return git_dir.join(WORK_DIRECTORY);
64}
65
66pub fn is_acceptable(item: &WorkItem, git_dir: &Path) -> bool {
77 return Path::new(item.repository.as_str()).is_absolute()
78 && Path::new(item.repository.as_str()).is_dir()
79 && Path::new(item.git_dir.as_str()) == git_dir
80 && item.branch_ref.starts_with("refs/")
81 && !item.branch_ref.contains(['\0', '\n', ' '])
82 && item.oid.len() >= 40
83 && item.oid.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
84}
85
86fn settle_all(work_dir: &Path, held: &HeldClaims, claimed: &[super::work_item::Claimed], state: &str) {
88 for item in claimed {
89 if let Err(reason) = settle(work_dir, item, state) {
90 eprintln!("cli-git-service: {reason}");
91 }
92 held.release(item.id.as_str());
93 }
94}
95
96fn is_bare_common_directory(common: &Path) -> bool {
109 let Ok(text) = std::fs::read(common.join("config")) else {
110 return false;
111 };
112 let Some(entries) = parse_config_text(text.as_slice()) else {
113 return false;
114 };
115 return last_entry(entries.as_slice(), "core.bare").is_some_and(|entry| { return parse_bool(entry.value.as_deref()) == Some(true); });
116}
117
118pub fn drain(git_dir: &Path, handlers: &dyn WorkHandlers, now: SystemTime) {
130 let work_dir: PathBuf = work_directory(git_dir);
131 if is_bare_common_directory(git_dir) {
132 let _requeued = requeue_expired(work_dir.as_path(), now, SHARED_CLAIM_LEASE, MAX_TRIES);
134 drain_pending(git_dir, handlers, now, Some(SHARED_CLAIM_LEASE));
135 return;
136 }
137 let _requeued = requeue_claimed(work_dir.as_path(), MAX_TRIES);
141 drain_pending(git_dir, handlers, now, None);
142}
143
144pub fn drain_common_records(git_dir: &Path, handlers: &dyn WorkHandlers, now: SystemTime) {
160 drain_common_records_with_lease(git_dir, handlers, now, SHARED_CLAIM_LEASE);
161}
162
163pub fn drain_common_records_with_lease(git_dir: &Path, handlers: &dyn WorkHandlers, now: SystemTime, lease: std::time::Duration) {
165 let Some(common) = common_directory_of(git_dir) else { return };
166 if common == git_dir || !is_bare_common_directory(common.as_path()) || !work_directory(common.as_path()).is_dir() {
167 return;
168 }
169 let _requeued = requeue_expired(work_directory(common.as_path()).as_path(), now, lease, MAX_TRIES);
170 drain_pending(common.as_path(), handlers, now, Some(lease));
171}
172
173fn common_directory_of(git_dir: &Path) -> Option<PathBuf> {
175 let text: String = std::fs::read_to_string(git_dir.join("commondir")).ok()?;
176 let named: &Path = Path::new(text.trim_end_matches(['\n', '\r']));
177 if named.as_os_str().is_empty() {
178 return None;
179 }
180 let mut resolved: PathBuf = PathBuf::new();
183 for part in git_dir.join(named).components() {
184 match part {
185 std::path::Component::ParentDir => {
186 if !resolved.pop() {
187 return None;
188 }
189 }
190 std::path::Component::CurDir => {}
191 other => resolved.push(other.as_os_str()),
192 }
193 }
194 return Some(resolved);
195}
196
197fn drain_pending(git_dir: &Path, handlers: &dyn WorkHandlers, now: SystemTime, lease: Option<std::time::Duration>) {
200 let work_dir: PathBuf = work_directory(git_dir);
201 let held: HeldClaims = HeldClaims::default();
202 let _renewal: Option<ClaimRenewal> = lease.map(|shared_lease| { return ClaimRenewal::start(work_dir.clone(), held.clone(), shared_lease / RENEWALS_PER_LEASE); });
203 loop {
204 let mut claimed: Vec<super::work_item::Claimed> = Vec::new();
205 loop {
206 match claim_next_of(work_dir.as_path(), now, &SERVICE_KINDS) {
207 Ok(Some(item)) => {
208 held.hold(item.id.as_str());
209 if lease.is_some() {
210 let _stamped = renew_claim(work_dir.as_path(), item.id.as_str(), SystemTime::now());
213 }
214 claimed.push(item);
215 }
216 Ok(None) => break,
217 Err(reason) => {
218 eprintln!("cli-git-service: {reason}");
219 break;
220 }
221 }
222 }
223 if claimed.is_empty() {
224 return;
225 }
226 let (acceptable, refused): (Vec<_>, Vec<_>) = claimed.into_iter().partition(|item| { return is_acceptable(&item.item, git_dir); });
227 settle_all(work_dir.as_path(), &held, &refused, FAILED);
228 let (pushes, rest): (Vec<_>, Vec<_>) = acceptable.into_iter().partition(|item| { return item.item.kind == WorkKind::Push; });
229 let (reviews, upkeep): (Vec<_>, Vec<_>) = rest.into_iter().partition(|item| { return item.item.kind == WorkKind::Review; });
230 let mut groups: Vec<((String, String), Vec<super::work_item::Claimed>)> = Vec::new();
231 for item in pushes {
232 let key = (item.item.repository.clone(), item.item.branch_ref.clone());
233 match groups.iter_mut().find(|(existing, _)| { return *existing == key; }) {
234 Some((_, members)) => members.push(item),
235 None => groups.push((key, vec![item])),
236 }
237 }
238 for (_, members) in groups {
239 let items: Vec<WorkItem> = members.iter().map(|member| { return member.item.clone(); }).collect();
240 match handlers.push(items.as_slice()) {
241 Verdict::Done => {
242 if let Some(newest) = members.last() {
245 clear_push_failures(git_dir, newest.item.branch_ref.as_str());
246 }
247 settle_all(work_dir.as_path(), &held, &members, DONE);
248 }
249 Verdict::Failed(text) => {
250 if let Some(newest) = members.last()
252 && let Err(reason) = record_push_failure(git_dir, newest.item.branch_ref.as_str(), newest.item.oid.as_str(), text.as_str())
253 {
254 eprintln!("cli-git-service: the failed push of {} could not be recorded for the person: {reason}", newest.item.oid);
255 }
256 settle_all(work_dir.as_path(), &held, &members, FAILED);
257 }
258 }
259 }
260 let mut upkept: Vec<String> = Vec::new();
261 for item in upkeep {
262 if !upkept.contains(&item.item.repository) {
263 upkept.push(item.item.repository.clone());
264 if let Verdict::Failed(reason) = handlers.maintenance(&item.item) {
265 eprintln!("cli-git-service: automatic maintenance did not run: {reason}");
266 }
267 }
268 settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), DONE);
269 }
270 for item in reviews {
272 match handlers.review(&item.item) {
273 Verdict::Done => settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), DONE),
274 Verdict::Failed(reason) => {
275 eprintln!("cli-git-service: the review of {} failed: {reason}", item.item.oid);
276 settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), FAILED);
277 }
278 }
279 }
280 }
281}
282
283pub const SHARED_CLAIM_LEASE: std::time::Duration = std::time::Duration::from_secs(900);
286
287pub fn run_round(wakes: Vec<Wake>, handlers: &GitHandlers, now: SystemTime) {
299 let mut directories: Vec<PathBuf> = Vec::new();
300 for (directory, review_key) in wakes {
301 if let Some(key) = review_key {
302 handlers.remember_review_key(directory.as_path(), key);
303 }
304 if !directories.contains(&directory) {
305 directories.push(directory);
306 }
307 }
308 for directory in directories {
309 drain(directory.as_path(), handlers, now);
310 drain_common_records(directory.as_path(), handlers, now);
311 handlers.forget_review_key(directory.as_path());
312 }
313}
314
315pub struct GitHandlers {
317 pub context: GitContext,
319 review_keys: std::sync::Mutex<std::collections::HashMap<PathBuf, ReviewKey>>,
322 #[cfg(test)]
324 review_endpoint: Option<String>,
325}
326
327impl GitHandlers {
329 pub fn new(context: GitContext) -> GitHandlers {
331 return GitHandlers {
332 context,
333 review_keys: std::sync::Mutex::new(std::collections::HashMap::new()),
334 #[cfg(test)]
335 review_endpoint: None,
336 };
337 }
338
339 #[cfg(test)]
341 pub fn with_review_endpoint(mut self, endpoint: &str) -> GitHandlers {
342 self.review_endpoint = Some(endpoint.to_string());
343 return self;
344 }
345
346 pub fn remember_review_key(&self, git_dir: &Path, key: ReviewKey) {
348 self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(git_dir.to_path_buf(), key);
349 }
350
351 pub fn forget_review_key(&self, git_dir: &Path) {
353 self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(git_dir);
354 }
355
356 fn review_key_of(&self, git_dir: &Path) -> Option<ReviewKey> {
357 return self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(git_dir).cloned();
358 }
359
360 fn push_git(&self, item: &WorkItem) -> PushGit {
361 let cwd: PathBuf = PathBuf::from(item.repository.as_str());
362 return PushGit {
363 real_git: self.context.real_git.clone(),
364 lfs: read_config_snapshot(&self.context, cwd.as_path()).as_ref().is_none_or(uses_lfs),
365 cwd,
366 environment: std::env::vars_os().collect(),
367 };
368 }
369}
370
371impl WorkHandlers for GitHandlers {
373 fn push(&self, items: &[WorkItem]) -> Verdict {
374 let Some(newest) = items.last() else { return Verdict::Done };
375 let git: PushGit = self.push_git(newest);
376 let mut output: Vec<u8> = Vec::new();
377 return match push_once(&git, newest.branch_ref.as_str(), newest.oid.as_str(), &mut output) {
378 Ok(result) if result.exit_code == 0 => Verdict::Done,
379 Ok(result) => Verdict::Failed(result.shown),
380 Err(error) => Verdict::Failed(format!("{error}")),
381 };
382 }
383
384 fn maintenance(&self, item: &WorkItem) -> Verdict {
385 return match run_auto_maintenance_with(&self.context, Path::new(item.repository.as_str()), None) {
386 Ok(AutoMaintenanceOutcome::Exited(code)) if code != 0 => Verdict::Failed(format!("it exited with status {code}")),
387 Ok(_) => Verdict::Done,
388 Err(error) => Verdict::Failed(format!("{error:?}")),
389 };
390 }
391
392 fn review(&self, item: &WorkItem) -> Verdict {
393 let key: Option<ReviewKey> = self.review_key_of(Path::new(item.git_dir.as_str()));
394 #[cfg_attr(not(test), expect(unused_mut, reason = "only a test replaces the endpoint"))]
395 let mut options: ReviewOptions = options_for(item, &review_variables(key.as_ref()));
396 #[cfg(test)]
397 if let Some(endpoint) = self.review_endpoint.as_ref() {
398 options.endpoint = endpoint.clone();
399 }
400 return review_item(&self.context, item, options, BUDGET);
401 }
402}
403
404#[cfg(test)]
406#[path = "service_work_tests.rs"]
407mod tests;