1use super::auto_push::push_once;
19use super::auto_push_single_flight::PushGit;
20use super::config_snapshot::read_config_snapshot;
21use super::lfs_pre_push::uses_lfs;
22use super::maintenance_run::{AutoMaintenanceOutcome, run_auto_maintenance_with};
23use super::auto_push::AutoPushOutcome;
24use super::claim_time::ClaimTime;
25use super::push_failure_notice::{clear_push_failures_before, record_push_failure, record_push_skip};
26use super::repo_read_config_text::{last_entry, parse_bool, parse_config_text};
27use super::service_claim_renewal::{ClaimRenewal, HeldClaims, RENEWALS_PER_LEASE};
28use super::service_review::{BUDGET, options_for, review_item, review_variables};
29use super::service_review_key::ReviewKey;
30use super::standards_review_run::ReviewOptions;
31use super::transaction_git::GitContext;
32use super::work_item::{Claimed, DONE, FAILED, WORK_DIRECTORY, WorkItem, WorkKind, claim_next_of, next_claim_expiry, requeue_expired_reporting};
33use std::path::{Path, PathBuf};
34use std::time::Duration;
35
36pub const SERVICE_KINDS: [WorkKind; 3] = [WorkKind::Push, WorkKind::Maintenance, WorkKind::Review];
38
39pub const MAX_TRIES: u32 = 3;
41
42pub type Wake = (PathBuf, Option<ReviewKey>);
44
45#[derive(Clone, Debug, Eq, PartialEq)]
47pub enum Verdict {
48 Done,
50 Failed(String),
52 Skipped(String),
54}
55
56pub trait WorkHandlers {
58 fn push(&self, items: &[WorkItem]) -> Verdict;
60 fn maintenance(&self, item: &WorkItem) -> Verdict;
62 fn review(&self, item: &WorkItem) -> Verdict;
64}
65
66pub fn work_directory(git_dir: &Path) -> PathBuf {
68 return git_dir.join(WORK_DIRECTORY);
69}
70
71pub fn is_acceptable(item: &WorkItem, git_dir: &Path) -> bool {
82 return Path::new(item.repository.as_str()).is_absolute()
83 && Path::new(item.repository.as_str()).is_dir()
84 && Path::new(item.git_dir.as_str()) == git_dir
85 && item.branch_ref.starts_with("refs/")
86 && !item.branch_ref.contains(['\0', '\n', ' '])
87 && item.oid.len() >= 40
88 && item.oid.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
89}
90
91fn settle_all(work_dir: &Path, held: &HeldClaims, claimed: &[Claimed], state: &str) {
93 for item in claimed {
94 if let Err(reason) = held.settle(work_dir, item.id.as_str(), state) {
95 eprintln!("cli-git-service: {reason}");
96 }
97 }
98}
99
100fn still_held(held: &HeldClaims, claimed: Vec<Claimed>) -> Vec<Claimed> {
111 let (mine, lost): (Vec<Claimed>, Vec<Claimed>) = claimed.into_iter().partition(|item| { return held.is_held(item.id.as_str()); });
112 for item in lost {
113 eprintln!(
114 "cli-git-service: the claim on {} ran out before this instance could run it, so another instance took the work over and it is not run here",
115 item.id
116 );
117 held.release(item.id.as_str());
118 }
119 return mine;
120}
121
122fn is_bare_common_directory(common: &Path) -> bool {
139 let Ok(text) = std::fs::read(common.join("config")) else {
140 return false;
141 };
142 let Some(mut entries) = parse_config_text(text.as_slice()) else {
143 return false;
144 };
145 let worktree_config: bool = last_entry(entries.as_slice(), "extensions.worktreeconfig").is_some_and(|entry| { return parse_bool(entry.value.as_deref()) == Some(true); })
146 && last_entry(entries.as_slice(), "core.repositoryformatversion").is_some();
147 if worktree_config {
148 match std::fs::read(common.join("config.worktree")) {
150 Ok(bytes) => match parse_config_text(bytes.as_slice()) {
151 Some(more) => entries.extend(more),
152 None => return false,
153 },
154 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
155 Err(_) => return false,
156 }
157 }
158 return last_entry(entries.as_slice(), "core.bare").is_some_and(|entry| { return parse_bool(entry.value.as_deref()) == Some(true); });
159}
160
161fn tell_about_given_up(git_dir: &Path, given_up: &[WorkItem]) {
173 for item in given_up.iter().filter(|item| { return item.kind == WorkKind::Push; }) {
174 let reason: String = format!(
175 "cli-git: the commit service stopped {MAX_TRIES} times before it finished pushing {} (a restart, a kill or a crash each time), so it gave up on that push.",
176 item.branch_ref
177 );
178 if let Err(problem) = record_push_skip(git_dir, item.branch_ref.as_str(), item.oid.as_str(), reason.as_str()) {
179 eprintln!("cli-git-service: the abandoned push of {} could not be recorded for the person: {problem}", item.oid);
180 }
181 }
182}
183
184pub const CLAIM_LEASE: Duration = Duration::from_secs(120);
188
189pub fn drain(git_dir: &Path, handlers: &dyn WorkHandlers, now: &ClaimTime) -> Option<Duration> {
205 return drain_with_lease(git_dir, handlers, now, CLAIM_LEASE);
206}
207
208pub fn drain_with_lease(git_dir: &Path, handlers: &dyn WorkHandlers, now: &ClaimTime, lease: Duration) -> Option<Duration> {
210 let work_dir: PathBuf = work_directory(git_dir);
211 tell_about_given_up(git_dir, requeue_expired_reporting(work_dir.as_path(), now, lease, MAX_TRIES).given_up.as_slice());
212 drain_pending(git_dir, handlers, lease);
213 return next_claim_expiry(work_dir.as_path(), &ClaimTime::read(), lease);
214}
215
216pub fn drain_common_records(git_dir: &Path, handlers: &dyn WorkHandlers, now: &ClaimTime) -> Option<Duration> {
232 return drain_common_records_with_lease(git_dir, handlers, now, CLAIM_LEASE);
233}
234
235pub fn drain_common_records_with_lease(git_dir: &Path, handlers: &dyn WorkHandlers, now: &ClaimTime, lease: Duration) -> Option<Duration> {
237 let common = common_directory_of(git_dir)?;
238 if common == git_dir || !is_bare_common_directory(common.as_path()) || !work_directory(common.as_path()).is_dir() {
239 return None;
240 }
241 tell_about_given_up(common.as_path(), requeue_expired_reporting(work_directory(common.as_path()).as_path(), now, lease, MAX_TRIES).given_up.as_slice());
242 drain_pending(common.as_path(), handlers, lease);
243 return next_claim_expiry(work_directory(common.as_path()).as_path(), &ClaimTime::read(), lease);
244}
245
246fn common_directory_of(git_dir: &Path) -> Option<PathBuf> {
248 let text: String = std::fs::read_to_string(git_dir.join("commondir")).ok()?;
249 let named: &Path = Path::new(text.trim_end_matches(['\n', '\r']));
250 if named.as_os_str().is_empty() {
251 return None;
252 }
253 let mut resolved: PathBuf = PathBuf::new();
256 for part in git_dir.join(named).components() {
257 match part {
258 std::path::Component::ParentDir => {
259 if !resolved.pop() {
260 return None;
261 }
262 }
263 std::path::Component::CurDir => {}
264 other => resolved.push(other.as_os_str()),
265 }
266 }
267 return Some(resolved);
268}
269
270fn drain_pending(git_dir: &Path, handlers: &dyn WorkHandlers, lease: Duration) {
273 let work_dir: PathBuf = work_directory(git_dir);
274 let held: HeldClaims = HeldClaims::default();
275 let _renewal: ClaimRenewal = ClaimRenewal::start(work_dir.clone(), held.clone(), lease / RENEWALS_PER_LEASE);
276 loop {
277 let mut claimed: Vec<Claimed> = Vec::new();
278 loop {
279 match claim_next_of(work_dir.as_path(), &ClaimTime::read(), &SERVICE_KINDS) {
282 Ok(Some(item)) => {
283 held.hold(&item);
284 claimed.push(item);
285 }
286 Ok(None) => break,
287 Err(reason) => {
288 eprintln!("cli-git-service: {reason}");
289 break;
290 }
291 }
292 }
293 if claimed.is_empty() {
294 return;
295 }
296 let (acceptable, refused): (Vec<_>, Vec<_>) = claimed.into_iter().partition(|item| { return is_acceptable(&item.item, git_dir); });
297 settle_all(work_dir.as_path(), &held, &refused, FAILED);
298 let (pushes, rest): (Vec<_>, Vec<_>) = acceptable.into_iter().partition(|item| { return item.item.kind == WorkKind::Push; });
299 let (reviews, upkeep): (Vec<_>, Vec<_>) = rest.into_iter().partition(|item| { return item.item.kind == WorkKind::Review; });
300 let mut groups: Vec<((String, String), Vec<Claimed>)> = Vec::new();
301 for item in pushes {
302 let key = (item.item.repository.clone(), item.item.branch_ref.clone());
303 match groups.iter_mut().find(|(existing, _)| { return *existing == key; }) {
304 Some((_, members)) => members.push(item),
305 None => groups.push((key, vec![item])),
306 }
307 }
308 for (_, claimed_members) in groups {
309 let members: Vec<Claimed> = still_held(&held, claimed_members);
310 if members.is_empty() {
311 continue;
312 }
313 let items: Vec<WorkItem> = members.iter().map(|member| { return member.item.clone(); }).collect();
314 let pushing_since: std::time::SystemTime = std::time::SystemTime::now();
316 match handlers.push(items.as_slice()) {
317 Verdict::Done => {
318 if let Some(newest) = members.last() {
321 clear_push_failures_before(git_dir, newest.item.branch_ref.as_str(), pushing_since);
322 }
323 settle_all(work_dir.as_path(), &held, &members, DONE);
324 }
325 Verdict::Failed(text) => {
326 if let Some(newest) = members.last()
328 && let Err(reason) = record_push_failure(git_dir, newest.item.branch_ref.as_str(), newest.item.oid.as_str(), text.as_str())
329 {
330 eprintln!("cli-git-service: the failed push of {} could not be recorded for the person: {reason}", newest.item.oid);
331 }
332 settle_all(work_dir.as_path(), &held, &members, FAILED);
333 }
334 Verdict::Skipped(text) => {
335 let recorded: bool = match members.last() {
339 Some(newest) if !text.is_empty() => match record_push_skip(git_dir, newest.item.branch_ref.as_str(), newest.item.oid.as_str(), text.as_str()) {
340 Ok(()) => true,
341 Err(reason) => {
342 eprintln!("cli-git-service: the skipped push of {} could not be recorded for the person: {reason}", newest.item.oid);
343 false
344 }
345 },
346 _ => true,
347 };
348 settle_all(work_dir.as_path(), &held, &members, if recorded { DONE } else { FAILED });
349 }
350 }
351 }
352 let mut upkept: Vec<String> = Vec::new();
353 for item in still_held(&held, upkeep) {
354 if !upkept.contains(&item.item.repository) {
355 upkept.push(item.item.repository.clone());
356 if let Verdict::Failed(reason) = handlers.maintenance(&item.item) {
357 eprintln!("cli-git-service: automatic maintenance did not run: {reason}");
358 }
359 }
360 settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), DONE);
361 }
362 for item in still_held(&held, reviews) {
364 match handlers.review(&item.item) {
365 Verdict::Done => settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), DONE),
366 Verdict::Failed(reason) => {
367 eprintln!("cli-git-service: the review of {} failed: {reason}", item.item.oid);
368 settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), FAILED);
369 }
370 Verdict::Skipped(reason) => {
371 eprintln!("cli-git-service: the review of {} was skipped: {reason}", item.item.oid);
372 settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), DONE);
373 }
374 }
375 }
376 }
377}
378
379pub fn run_round(wakes: Vec<Wake>, handlers: &GitHandlers, now: &ClaimTime) -> Vec<(PathBuf, Duration)> {
393 let mut directories: Vec<PathBuf> = Vec::new();
394 for (directory, review_key) in wakes {
395 if let Some(key) = review_key {
396 handlers.remember_review_key(directory.as_path(), key);
397 }
398 if !directories.contains(&directory) {
399 directories.push(directory);
400 }
401 }
402 let mut retries: Vec<(PathBuf, Duration)> = Vec::new();
403 for directory in directories {
404 let waits: [Option<Duration>; 2] = [drain(directory.as_path(), handlers, now), drain_common_records(directory.as_path(), handlers, now)];
405 if let Some(wait) = waits.into_iter().flatten().min() {
406 retries.push((directory.clone(), wait));
407 }
408 handlers.forget_review_key(directory.as_path());
409 }
410 return retries;
411}
412
413pub struct GitHandlers {
415 pub context: GitContext,
417 review_keys: std::sync::Mutex<std::collections::HashMap<PathBuf, ReviewKey>>,
420 #[cfg(test)]
422 review_endpoint: Option<String>,
423}
424
425impl GitHandlers {
427 pub fn new(context: GitContext) -> GitHandlers {
429 return GitHandlers {
430 context,
431 review_keys: std::sync::Mutex::new(std::collections::HashMap::new()),
432 #[cfg(test)]
433 review_endpoint: None,
434 };
435 }
436
437 #[cfg(test)]
439 pub fn with_review_endpoint(mut self, endpoint: &str) -> GitHandlers {
440 self.review_endpoint = Some(endpoint.to_string());
441 return self;
442 }
443
444 pub fn remember_review_key(&self, git_dir: &Path, key: ReviewKey) {
446 self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(git_dir.to_path_buf(), key);
447 }
448
449 pub fn forget_review_key(&self, git_dir: &Path) {
451 self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(git_dir);
452 }
453
454 fn review_key_of(&self, git_dir: &Path) -> Option<ReviewKey> {
455 return self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(git_dir).cloned();
456 }
457
458 fn push_git(&self, item: &WorkItem) -> PushGit {
459 let cwd: PathBuf = PathBuf::from(item.repository.as_str());
460 return PushGit {
461 real_git: self.context.real_git.clone(),
462 lfs: read_config_snapshot(&self.context, cwd.as_path()).as_ref().is_none_or(uses_lfs),
463 cwd,
464 environment: std::env::vars_os().collect(),
465 };
466 }
467}
468
469impl WorkHandlers for GitHandlers {
471 fn push(&self, items: &[WorkItem]) -> Verdict {
472 let Some(newest) = items.last() else { return Verdict::Done };
473 let git: PushGit = self.push_git(newest);
474 let mut output: Vec<u8> = Vec::new();
475 return match push_once(&git, newest.branch_ref.as_str(), newest.oid.as_str(), &mut output) {
476 Ok(result) if result.outcome == AutoPushOutcome::Skipped => Verdict::Skipped(result.shown),
479 Ok(result) if result.exit_code == 0 => Verdict::Done,
480 Ok(result) => Verdict::Failed(result.shown),
481 Err(error) => Verdict::Failed(format!("{error}")),
482 };
483 }
484
485 fn maintenance(&self, item: &WorkItem) -> Verdict {
486 return match run_auto_maintenance_with(&self.context, Path::new(item.repository.as_str()), None) {
487 Ok(AutoMaintenanceOutcome::Exited(code)) if code != 0 => Verdict::Failed(format!("it exited with status {code}")),
488 Ok(_) => Verdict::Done,
489 Err(error) => Verdict::Failed(format!("{error:?}")),
490 };
491 }
492
493 fn review(&self, item: &WorkItem) -> Verdict {
494 let key: Option<ReviewKey> = self.review_key_of(Path::new(item.git_dir.as_str()));
495 #[cfg_attr(not(test), expect(unused_mut, reason = "only a test replaces the endpoint"))]
496 let mut options: ReviewOptions = options_for(item, &review_variables(key.as_ref()));
497 #[cfg(test)]
498 if let Some(endpoint) = self.review_endpoint.as_ref() {
499 options.endpoint = endpoint.clone();
500 }
501 return review_item(&self.context, item, options, BUDGET);
502 }
503}
504
505#[cfg(test)]
507#[path = "service_work_tests.rs"]
508mod tests;