Skip to main content

git_policy_cli/
service_work.rs

1//! What:
2//!  The service's consumer of after-commit work: pending `push` and `maintenance` work items of one repository are
3//!  claimed, coalesced, run, settled, and a push that failed leaves a record that the person's next wrapper command shows.
4//! Why:
5//!  A commit must not wait for the network or for upkeep, so the wrapper leaves a record and the resident service does
6//!  the work on its own thread (`doc/planning/cli-git-resident-service.md`, "After-commit work"). The service is the
7//!  only pusher of the repositories it serves, so no lock is needed: several pending pushes of one branch become one
8//!  push, and a service that died mid-push is noticed by the lease of its claims running out, never by a process id: the
9//!  holder renews the lease while it works, so a successor that starts beside a holder that is still running leaves its claims
10//!  alone, and one that starts after the holder died takes them back as soon as the lease is over. Other kinds of work
11//!  (the standards review) are never claimed here.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! for (const group of coalesce(await claimAll(workDir, ['push', 'maintenance']))) settle(group, await handler(group));
16//! ```
17
18use super::auto_push::push_once;
19use super::journald_native::Priority;
20use super::auto_push_single_flight::PushGit;
21use super::config_snapshot::read_config_snapshot;
22use super::lfs_pre_push::uses_lfs;
23use super::maintenance_run::{AutoMaintenanceOutcome, run_auto_maintenance_with};
24use super::auto_push::AutoPushOutcome;
25use super::claim_time::ClaimTime;
26use super::push_failure_notice::{clear_push_failures_before, record_push_failure, record_push_skip};
27use super::repo_read_config_text::{last_entry, parse_bool, parse_config_text};
28use super::service_claim_renewal::{ClaimRenewal, HeldClaims, Renewal, renewal_interval};
29use super::service_review::{BUDGET, options_for, review_item, review_variables};
30use super::service_review_key::ReviewKey;
31use super::service_timing::log_entry;
32use super::standards_review_run::ReviewOptions;
33use super::transaction_git::GitContext;
34use super::work_item::{Claimed, DONE, FAILED, WORK_DIRECTORY, WorkItem, WorkKind, claim_next_of, next_claim_expiry, requeue_expired_reporting};
35use std::path::{Path, PathBuf};
36use std::time::{Duration, Instant};
37
38/// The kinds of work the service runs itself.
39pub const SERVICE_KINDS: [WorkKind; 3] = [WorkKind::Push, WorkKind::Maintenance, WorkKind::Review];
40
41/// How many times an item is tried before it is given up on.
42pub const MAX_TRIES: u32 = 3;
43
44/// What a wake message brings: the repository to work on and the review credential that came with it, if any.
45pub type Wake = (PathBuf, Option<ReviewKey>);
46
47/// What a handler came to.
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub enum Verdict {
50    /// The work was done, or there was nothing to do.
51    Done,
52    /// The work failed; the text is what the person should be told.
53    Failed(String),
54    /// Nothing was done and nothing is left to retry, but the person should be told why; the text is what they are told.
55    Skipped(String),
56}
57
58/// What runs each kind of work.
59pub trait WorkHandlers {
60    /// Push the branch of these items, which share a repository and a branch; the last is the newest.
61    fn push(&self, items: &[WorkItem]) -> Verdict;
62    /// Run the repository's automatic upkeep.
63    fn maintenance(&self, item: &WorkItem) -> Verdict;
64    /// Review one landed commit into the repository's shadow log.
65    fn review(&self, item: &WorkItem) -> Verdict;
66}
67
68/// The work directory of a repository.
69pub fn work_directory(git_dir: &Path) -> PathBuf {
70    return git_dir.join(WORK_DIRECTORY);
71}
72
73/// What:
74///  Whether an item is one this service should act on for this repository.
75/// Why:
76///   Anyone who can write the work directory can write an item, so the fields that become a directory, a reference or a
77///  program argument are checked before they are used.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// isAcceptable(item, gitDir)
82/// ```
83pub fn is_acceptable(item: &WorkItem, git_dir: &Path) -> bool {
84    return Path::new(item.repository.as_str()).is_absolute()
85        && Path::new(item.repository.as_str()).is_dir()
86        && Path::new(item.git_dir.as_str()) == git_dir
87        && item.branch_ref.starts_with("refs/")
88        && !item.branch_ref.contains(['\0', '\n', ' '])
89        && item.oid.len() >= 40
90        && item.oid.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
91}
92
93/// Moves the claims to `state` and stops renewing them; a claim that another instance took over is said, and left to it.
94fn settle_all(work_dir: &Path, held: &HeldClaims, claimed: &[Claimed], state: &str) {
95    for item in claimed {
96        if let Err(reason) = held.settle(work_dir, item.id.as_str(), state) {
97            crate::service_warn!("{reason}");
98        }
99    }
100}
101
102/// What:
103///  The claims of `claimed` that this instance still holds, before their work starts.
104/// Why:
105///   A claim whose renewal found it gone was taken over: another instance runs that item now, so running it here as well would
106///  do it twice. The claim is forgotten and the loss said, and nothing else is done for it.
107///
108/// In TS you'd write (pseudocode):
109/// ```ts
110/// claimed.filter((c) => held.isHeld(c.id))
111/// ```
112fn still_held(held: &HeldClaims, work_dir: &Path, claimed: Vec<Claimed>) -> Vec<Claimed> {
113    let (mine, lost): (Vec<Claimed>, Vec<Claimed>) = claimed.into_iter().partition(|item| { return held.is_held_on_disk(work_dir, item.id.as_str()); });
114    for item in lost {
115        crate::service_warn!("the claim on {} ran out before this instance could run it, so another instance took the work over and it is not run here",
116            item.id
117        );
118        held.release(item.id.as_str());
119    }
120    return mine;
121}
122
123/// What:
124///  Whether a common Git directory belongs to a repository with no main worktree, which `core.bare` in its own `config`, or in its
125///  `config.worktree` when `extensions.worktreeConfig` turns that file on, says.
126/// Why:
127///   A bare repository has no instance whose Git directory is the common one, so the instances of its linked worktrees are the
128///  only ones that can run the records left there; a repository with a main worktree has that instance, which owns them.
129///   Git moves `core.bare` into `config.worktree` when the extension is enabled (`git help config`, "extensions.worktreeConfig"),
130///  and reads that file after `config` only while the extension is on and the repository has a format version
131///  (`repo_read_layout.rs`), so the last `core.bare` of the two decides. An unreadable or unparsable file, or a spelling this
132///  reader leaves to Git, reads as not bare, and the records then stay for the main worktree's instance, which is the safe side
133///  of a wrong answer.
134///
135/// In TS you'd write (pseudocode):
136/// ```ts
137/// isBareCommonDirectory(commonDir)
138/// ```
139fn is_bare_common_directory(common: &Path) -> bool {
140    let Ok(text) = std::fs::read(common.join("config")) else {
141        return false;
142    };
143    let Some(mut entries) = parse_config_text(text.as_slice()) else {
144        return false;
145    };
146    let worktree_config: bool = last_entry(entries.as_slice(), "extensions.worktreeconfig").is_some_and(|entry| { return parse_bool(entry.value.as_deref()) == Some(true); })
147        && last_entry(entries.as_slice(), "core.repositoryformatversion").is_some();
148    if worktree_config {
149        // A missing file is an empty file; one that is there and cannot be read or parsed leaves the answer to Git.
150        match std::fs::read(common.join("config.worktree")) {
151            Ok(bytes) => match parse_config_text(bytes.as_slice()) {
152                Some(more) => entries.extend(more),
153                None => return false,
154            },
155            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
156            Err(_) => return false,
157        }
158    }
159    return last_entry(entries.as_slice(), "core.bare").is_some_and(|entry| { return parse_bool(entry.value.as_deref()) == Some(true); });
160}
161
162/// What:
163///  Leaves a notice for each push the service gave up on without ever finishing it: its holder was stopped, or lost, on every
164///  try.
165/// Why:
166///   Such an item moves to `failed` by recovery, not by a handler, so nothing else records why the commit was never backed up
167///  and the person would not be told. The notice is the one a skipped push leaves, because nothing failed and nothing was sent.
168///
169/// In TS you'd write (pseudocode):
170/// ```ts
171/// for (const item of givenUp.filter(isPush)) await recordPushSkip(gitDir, item, reason)
172/// ```
173fn tell_about_given_up(git_dir: &Path, given_up: &[WorkItem]) {
174    for item in given_up.iter().filter(|item| { return item.kind == WorkKind::Push; }) {
175        let reason: String = format!(
176            "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.",
177            item.branch_ref
178        );
179        if let Err(problem) = record_push_skip(git_dir, item.branch_ref.as_str(), item.oid.as_str(), reason.as_str()) {
180            crate::service_error!("the abandoned push of {} could not be recorded for the person: {problem}", item.oid);
181        }
182    }
183}
184
185/// How long a claim goes without a renewal before another instance runs its item again; the holder renews it three times in this
186/// span while it works (`service_claim_renewal.rs`), so a holder that is slow keeps its claim and one that died loses it
187/// after this long.
188pub const CLAIM_LEASE: Duration = Duration::from_secs(120);
189
190/// What:
191///  Runs everything pending for one repository: takes back the claims whose holder stopped renewing them, claims every pending
192///  push, maintenance and review item, runs one push per branch and one upkeep per repository, settles the items, and leaves a
193///  notice for each failed or skipped push. Gives how long until a claim another instance still holds runs out its lease, or
194///  nothing when there is none.
195/// Why:
196///   Coalescing keeps a burst of commits to one push; a handler that fails leaves the items in `failed`, not in a loop. A
197///  claim found in this directory may belong to a live instance (a restarted service starts beside the one it replaces, and the
198///  linked worktrees of a bare repository share a directory), so only its lease says it was abandoned; the caller is told when
199///  to look again, because nothing else would wake it once the lease is over.
200///
201/// In TS you'd write (pseudocode):
202/// ```ts
203/// const wait = await drain(gitDir, handlers, readClaimTime())
204/// ```
205pub fn drain(git_dir: &Path, handlers: &dyn WorkHandlers, now: &ClaimTime) -> Option<Duration> {
206    return drain_with_lease(git_dir, handlers, now, CLAIM_LEASE);
207}
208
209/// `drain` with the lease given, so a test can use a short one.
210pub fn drain_with_lease(git_dir: &Path, handlers: &dyn WorkHandlers, now: &ClaimTime, lease: Duration) -> Option<Duration> {
211    return drain_with_renewal(git_dir, handlers, now, lease, Renewal::Timed);
212}
213
214/// `drain_with_lease` with the way its claims are kept fresh given,
215/// so a control can drive the renewal itself.
216pub fn drain_with_renewal(git_dir: &Path, handlers: &dyn WorkHandlers, now: &ClaimTime, lease: Duration, renewal: Renewal<'_>) -> Option<Duration> {
217    let work_dir: PathBuf = work_directory(git_dir);
218    tell_about_given_up(git_dir, requeue_expired_reporting(work_dir.as_path(), now, lease, MAX_TRIES).given_up.as_slice());
219    drain_pending(git_dir, handlers, lease, renewal);
220    return next_claim_expiry(work_dir.as_path(), &ClaimTime::read(), lease);
221}
222
223/// What:
224///  Runs the records the releases before one service per worktree left in the common directory of a bare repository, from the
225///  instance of a linked worktree: nothing for a repository with a main worktree, for a main worktree and for a directory with
226///  no `commondir`.
227/// Why:
228///   Those releases kept every worktree's records in the common directory. A repository with a main worktree has an instance
229///  whose own directory that is, and it runs them (the main worktree's current records live there too), so a linked instance
230///  must not. A repository whose worktrees are all linked has no such instance, and its pushes would sit there with no notice.
231///   Several linked instances may drain the directory at once, so a claim is taken back only after its lease, which the holder
232///  renews while it works.
233///
234/// In TS you'd write (pseudocode):
235/// ```ts
236/// const wait = await drainCommonRecords(linkedGitDir, handlers, now)
237/// ```
238pub fn drain_common_records(git_dir: &Path, handlers: &dyn WorkHandlers, now: &ClaimTime) -> Option<Duration> {
239    return drain_common_records_with_lease(git_dir, handlers, now, CLAIM_LEASE);
240}
241
242/// `drain_common_records` with the lease given, so a test can use a short one.
243pub fn drain_common_records_with_lease(git_dir: &Path, handlers: &dyn WorkHandlers, now: &ClaimTime, lease: Duration) -> Option<Duration> {
244    let common = common_directory_of(git_dir)?;
245    if common == git_dir || !is_bare_common_directory(common.as_path()) || !work_directory(common.as_path()).is_dir() {
246        return None;
247    }
248    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());
249    drain_pending(common.as_path(), handlers, lease, Renewal::Timed);
250    return next_claim_expiry(work_directory(common.as_path()).as_path(), &ClaimTime::read(), lease);
251}
252
253/// The common directory a linked worktree's Git directory names in its `commondir` file, or nothing for any other directory.
254fn common_directory_of(git_dir: &Path) -> Option<PathBuf> {
255    let text: String = std::fs::read_to_string(git_dir.join("commondir")).ok()?;
256    let named: &Path = Path::new(text.trim_end_matches(['\n', '\r']));
257    if named.as_os_str().is_empty() {
258        return None;
259    }
260    // Resolved by the text alone, so the result spells the path the way the worktree's own Git directory does, which is how the
261    //  records name their directory.
262    let mut resolved: PathBuf = PathBuf::new();
263    for part in git_dir.join(named).components() {
264        match part {
265            std::path::Component::ParentDir => {
266                if !resolved.pop() {
267                    return None;
268                }
269            }
270            std::path::Component::CurDir => {}
271            other => resolved.push(other.as_os_str()),
272        }
273    }
274    return Some(resolved);
275}
276
277/// Claims and runs every pending item of one work directory until none is left; the claims held are renewed every third of
278/// `lease`, so that none of them looks abandoned to another instance while it waits or runs.
279fn drain_pending(git_dir: &Path, handlers: &dyn WorkHandlers, lease: Duration, renewal: Renewal<'_>) {
280    let work_dir: PathBuf = work_directory(git_dir);
281    let held: HeldClaims = HeldClaims::default();
282    let _renewing: Option<ClaimRenewal> = match renewal {
283        Renewal::Timed => Some(ClaimRenewal::start(work_dir.clone(), held.clone(), renewal_interval(lease))),
284        Renewal::Driven(driver) => {
285            driver.attach(work_dir.clone(), held.clone());
286            None
287        }
288    };
289    loop {
290        let mut claimed: Vec<Claimed> = Vec::new();
291        loop {
292            // Each claim carries the reading of the moment it is taken, not of the start of the round, which a long round makes
293            //  most of a lease old.
294            match claim_next_of(work_dir.as_path(), &ClaimTime::read(), &SERVICE_KINDS) {
295                Ok(Some(item)) => {
296                    held.hold(&item);
297                    claimed.push(item);
298                }
299                Ok(None) => break,
300                Err(reason) => {
301                    crate::service_error!("{reason}");
302                    break;
303                }
304            }
305        }
306        if claimed.is_empty() {
307            return;
308        }
309        let (acceptable, refused): (Vec<_>, Vec<_>) = claimed.into_iter().partition(|item| { return is_acceptable(&item.item, git_dir); });
310        settle_all(work_dir.as_path(), &held, &refused, FAILED);
311        let (pushes, rest): (Vec<_>, Vec<_>) = acceptable.into_iter().partition(|item| { return item.item.kind == WorkKind::Push; });
312        let (reviews, upkeep): (Vec<_>, Vec<_>) = rest.into_iter().partition(|item| { return item.item.kind == WorkKind::Review; });
313        let mut groups: Vec<((String, String), Vec<Claimed>)> = Vec::new();
314        for item in pushes {
315            let key = (item.item.repository.clone(), item.item.branch_ref.clone());
316            match groups.iter_mut().find(|(existing, _)| { return *existing == key; }) {
317                Some((_, members)) => members.push(item),
318                None => groups.push((key, vec![item])),
319            }
320        }
321        for (_, claimed_members) in groups {
322            let members: Vec<Claimed> = still_held(&held, work_dir.as_path(), claimed_members);
323            if members.is_empty() {
324                continue;
325            }
326            let items: Vec<WorkItem> = members.iter().map(|member| { return member.item.clone(); }).collect();
327            // The push carries what existed when it began; a notice written after this moment is about something newer.
328            let pushing_since: std::time::SystemTime = std::time::SystemTime::now();
329            let pushing: Instant = Instant::now();
330            let verdict: Verdict = handlers.push(items.as_slice());
331            if let Some(newest) = members.last() {
332                // One line for every push the service ran, so a branch that was not backed up (or was) is found by its name.
333                let (priority, outcome, said): (Priority, &str, &str) = match &verdict {
334                    Verdict::Done => (Priority::Info, "done", ""),
335                    Verdict::Failed(text) => (Priority::Warning, "failed", text.as_str()),
336                    Verdict::Skipped(text) => (Priority::Info, "skipped", text.as_str()),
337                };
338                let fields: [(&str, String); 3] = [
339                    ("CLI_GIT_BRANCH", newest.item.branch_ref.clone()),
340                    ("CLI_GIT_OID", newest.item.oid.clone()),
341                    ("CLI_GIT_OUTCOME", outcome.to_string()),
342                ];
343                log_entry(
344                    priority,
345                    format!("pushed branch={:?} oid={} items={} outcome={outcome} push_ms={}{}{said}", newest.item.branch_ref, newest.item.oid, members.len(), pushing.elapsed().as_millis(), if said.is_empty() { "" } else { ": " }).as_str(),
346                    &fields,
347                );
348            }
349            match verdict {
350                Verdict::Done => {
351                    // A push of the branch went through, so an earlier failure to push it is no longer true. The records go
352                    //  before the items settle: a service that dies between the two pushes again, and nothing is lost.
353                    if let Some(newest) = members.last() {
354                        clear_push_failures_before(git_dir, newest.item.branch_ref.as_str(), pushing_since);
355                    }
356                    settle_all(work_dir.as_path(), &held, &members, DONE);
357                }
358                Verdict::Failed(text) => {
359                    // The record goes before the items settle, so an item in `failed` always has its record.
360                    if let Some(newest) = members.last()
361                        && let Err(reason) = record_push_failure(git_dir, newest.item.branch_ref.as_str(), newest.item.oid.as_str(), text.as_str())
362                    {
363                        crate::service_error!("the failed push of {} could not be recorded for the person: {reason}", newest.item.oid);
364                    }
365                    settle_all(work_dir.as_path(), &held, &members, FAILED);
366                }
367                Verdict::Skipped(text) => {
368                    // Nothing was pushed, so an earlier failure of the branch is still true and stays. A skip with words leaves
369                    //  its own record before the items settle; one that could not be recorded settles as failed, so no item
370                    //  is done without the person having been told.
371                    let recorded: bool = match members.last() {
372                        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()) {
373                            Ok(()) => true,
374                            Err(reason) => {
375                                crate::service_error!("the skipped push of {} could not be recorded for the person: {reason}", newest.item.oid);
376                                false
377                            }
378                        },
379                        _ => true,
380                    };
381                    settle_all(work_dir.as_path(), &held, &members, if recorded { DONE } else { FAILED });
382                }
383            }
384        }
385        let mut upkept: Vec<String> = Vec::new();
386        for item in still_held(&held, work_dir.as_path(), upkeep) {
387            if !upkept.contains(&item.item.repository) {
388                upkept.push(item.item.repository.clone());
389                let upkeep_began: Instant = Instant::now();
390                match handlers.maintenance(&item.item) {
391                    Verdict::Failed(reason) => crate::service_warn!("automatic maintenance did not run: {reason}"),
392                    Verdict::Done | Verdict::Skipped(_) => crate::service_log!("automatic maintenance ran maintenance_ms={}", upkeep_began.elapsed().as_millis()),
393                };
394            }
395            settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), DONE);
396        }
397        // Reviews go last: a push is the person's backup, a review is a shadow record, and each is bounded by its budget.
398        for item in still_held(&held, work_dir.as_path(), reviews) {
399            let review_began: Instant = Instant::now();
400            let verdict: Verdict = handlers.review(&item.item);
401            if matches!(verdict, Verdict::Done) {
402                crate::service_log!("the review of {} is done review_ms={}", item.item.oid, review_began.elapsed().as_millis());
403            }
404            match verdict {
405                Verdict::Done => settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), DONE),
406                Verdict::Failed(reason) => {
407                    crate::service_warn!("the review of {} failed: {reason}", item.item.oid);
408                    settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), FAILED);
409                }
410                Verdict::Skipped(reason) => {
411                    crate::service_log!("the review of {} was skipped: {reason}", item.item.oid);
412                    settle_all(work_dir.as_path(), &held, std::slice::from_ref(&item), DONE);
413                }
414            }
415        }
416    }
417}
418
419/// What:
420///  Runs one round of wakes: keeps each wake's review credential in memory, drains each named repository once, and
421///  forgets the credentials when the repository is done. Gives, for each repository that has a claim another instance holds,
422///  how long until that claim's lease runs out.
423/// Why:
424///   The credential is held only for the reviews of the round that brought it; a service that restarts before the round
425///  runs has none, and its reviews record the plain "no key" outcome. The service looks at such a repository again when the
426///  time is over, because the holder may have died and nothing else would wake the work.
427///
428/// In TS you'd write (pseudocode):
429/// ```ts
430/// for (const [dir, key] of wakes) { remember(dir, key); await drain(dir); forget(dir) }
431/// ```
432pub fn run_round(wakes: Vec<Wake>, handlers: &GitHandlers, now: &ClaimTime) -> Vec<(PathBuf, Duration)> {
433    let mut directories: Vec<PathBuf> = Vec::new();
434    for (directory, review_key) in wakes {
435        if let Some(key) = review_key {
436            handlers.remember_review_key(directory.as_path(), key);
437        }
438        if !directories.contains(&directory) {
439            directories.push(directory);
440        }
441    }
442    let mut retries: Vec<(PathBuf, Duration)> = Vec::new();
443    for directory in directories {
444        let waits: [Option<Duration>; 2] = [drain(directory.as_path(), handlers, now), drain_common_records(directory.as_path(), handlers, now)];
445        if let Some(wait) = waits.into_iter().flatten().min() {
446            retries.push((directory.clone(), wait));
447        }
448        handlers.forget_review_key(directory.as_path());
449    }
450    return retries;
451}
452
453/// The handlers that run real Git.
454pub struct GitHandlers {
455    /// The context every Git child of the service runs in.
456    pub context: GitContext,
457    /// The review credential of each repository whose wake message carried one. Held in memory only, removed when the
458    /// round that used it ends.
459    review_keys: std::sync::Mutex<std::collections::HashMap<PathBuf, ReviewKey>>,
460    /// Where tests point the review's gateway call, so no test reaches the real endpoint.
461    #[cfg(test)]
462    review_endpoint: Option<String>,
463}
464
465/// Methods over the real handlers.
466impl GitHandlers {
467    /// Handlers that run real Git in `context`, with no review credential yet.
468    pub fn new(context: GitContext) -> GitHandlers {
469        return GitHandlers {
470            context,
471            review_keys: std::sync::Mutex::new(std::collections::HashMap::new()),
472            #[cfg(test)]
473            review_endpoint: None,
474        };
475    }
476
477    /// The same handlers with the review's gateway call sent to `endpoint`; tests only.
478    #[cfg(test)]
479    pub fn with_review_endpoint(mut self, endpoint: &str) -> GitHandlers {
480        self.review_endpoint = Some(endpoint.to_string());
481        return self;
482    }
483
484    /// Keep the credential for the reviews of this repository until `forget_review_key`.
485    pub fn remember_review_key(&self, git_dir: &Path, key: ReviewKey) {
486        self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(git_dir.to_path_buf(), key);
487    }
488
489    /// Drop the credential of this repository.
490    pub fn forget_review_key(&self, git_dir: &Path) {
491        self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(git_dir);
492    }
493
494    fn review_key_of(&self, git_dir: &Path) -> Option<ReviewKey> {
495        return self.review_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(git_dir).cloned();
496    }
497
498    fn push_git(&self, item: &WorkItem) -> PushGit {
499        let cwd: PathBuf = PathBuf::from(item.repository.as_str());
500        return PushGit {
501            real_git: self.context.real_git.clone(),
502            lfs: read_config_snapshot(&self.context, cwd.as_path()).as_ref().is_none_or(uses_lfs),
503            cwd,
504            environment: std::env::vars_os().collect(),
505        };
506    }
507}
508
509/// Trait implementation for the real Git.
510impl WorkHandlers for GitHandlers {
511    fn push(&self, items: &[WorkItem]) -> Verdict {
512        let Some(newest) = items.last() else { return Verdict::Done };
513        let git: PushGit = self.push_git(newest);
514        let mut output: Vec<u8> = Vec::new();
515        return match push_once(&git, newest.branch_ref.as_str(), newest.oid.as_str(), &mut output) {
516            // A skip sent nothing: with words they are told; with none (no upstream and no origin) the repository has nowhere
517            //  to back up to and there is nothing to say, but an earlier failure of the branch is still true.
518            Ok(result) if result.outcome == AutoPushOutcome::Skipped => Verdict::Skipped(result.shown),
519            Ok(result) if result.exit_code == 0 => Verdict::Done,
520            Ok(result) => Verdict::Failed(result.shown),
521            Err(error) => Verdict::Failed(format!("{error}")),
522        };
523    }
524
525    fn maintenance(&self, item: &WorkItem) -> Verdict {
526        return match run_auto_maintenance_with(&self.context, Path::new(item.repository.as_str()), None) {
527            Ok(AutoMaintenanceOutcome::Exited(code)) if code != 0 => Verdict::Failed(format!("it exited with status {code}")),
528            Ok(_) => Verdict::Done,
529            Err(error) => Verdict::Failed(format!("{error:?}")),
530        };
531    }
532
533    fn review(&self, item: &WorkItem) -> Verdict {
534        let key: Option<ReviewKey> = self.review_key_of(Path::new(item.git_dir.as_str()));
535        #[cfg_attr(not(test), expect(unused_mut, reason = "only a test replaces the endpoint"))]
536        let mut options: ReviewOptions = options_for(item, &review_variables(key.as_ref()));
537        #[cfg(test)]
538        if let Some(endpoint) = self.review_endpoint.as_ref() {
539            options.endpoint = endpoint.clone();
540        }
541        return review_item(&self.context, item, options, BUDGET);
542    }
543}
544
545/// Work consumer controls stay out of the release executable.
546#[cfg(test)]
547#[path = "service_work_tests.rs"]
548mod tests;