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 its successor's first drain, which takes back every claim it finds,
9//!  never by a process id. Other kinds of work
10//!  (the standards review) are never claimed here.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! for (const group of coalesce(await claimAll(workDir, ['push', 'maintenance']))) settle(group, await handler(group));
15//! ```
16
17use 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
33/// The kinds of work the service runs itself.
34pub const SERVICE_KINDS: [WorkKind; 3] = [WorkKind::Push, WorkKind::Maintenance, WorkKind::Review];
35
36/// How many times an item is tried before it is given up on.
37pub const MAX_TRIES: u32 = 3;
38
39/// What a wake message brings: the repository to work on and the review credential that came with it, if any.
40pub type Wake = (PathBuf, Option<ReviewKey>);
41
42/// What a handler came to.
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub enum Verdict {
45    /// The work was done, or there was nothing to do.
46    Done,
47    /// The work failed; the text is what the person should be told.
48    Failed(String),
49}
50
51/// What runs each kind of work.
52pub trait WorkHandlers {
53    /// Push the branch of these items, which share a repository and a branch; the last is the newest.
54    fn push(&self, items: &[WorkItem]) -> Verdict;
55    /// Run the repository's automatic upkeep.
56    fn maintenance(&self, item: &WorkItem) -> Verdict;
57    /// Review one landed commit into the repository's shadow log.
58    fn review(&self, item: &WorkItem) -> Verdict;
59}
60
61/// The work directory of a repository.
62pub fn work_directory(git_dir: &Path) -> PathBuf {
63    return git_dir.join(WORK_DIRECTORY);
64}
65
66/// What:
67///  Whether an item is one this service should act on for this repository.
68/// Why:
69///   Anyone who can write the work directory can write an item, so the fields that become a directory, a reference or a
70///  program argument are checked before they are used.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// isAcceptable(item, gitDir)
75/// ```
76pub 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
86/// Moves the claims to `state` and stops renewing them.
87fn 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
96/// What:
97///  Whether a common Git directory belongs to a repository with no main worktree, which `core.bare` in its own `config` says.
98/// Why:
99///   A bare repository has no instance whose Git directory is the common one, so the instances of its linked worktrees are the
100///  only ones that can run the records left there; a repository with a main worktree has that instance, which owns them.
101///   Only the common file is read: an unreadable or unparsable file, or a spelling this reader leaves to Git, reads as not
102///  bare, and the records then stay for the main worktree's instance, which is the safe side of a wrong answer.
103///
104/// In TS you'd write (pseudocode):
105/// ```ts
106/// isBareCommonDirectory(commonDir)
107/// ```
108fn 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
118/// What:
119///  Runs everything pending for one repository: takes back the claims an earlier service left, claims every pending push and maintenance
120///  item, runs one push per branch and one upkeep per repository, settles the items, and leaves a notice for each
121///  failed push.
122/// Why:
123///   Coalescing keeps a burst of commits to one push; a handler that fails leaves the items in `failed`, not in a loop.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// await drain(gitDir, handlers, new Date())
128/// ```
129pub 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        // The linked worktrees' instances drain this directory too, so a claim here may be live: only its lease says otherwise.
133        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    // This thread claims nothing before this line, and one service serves a worktree at a time, so a claim found in its own
138    //  work directory was left by a service that died or was asked to retire in the middle of a push. Waiting out a lease
139    //  would leave its work stranded for the lease's length; each such death costs the item one try.
140    let _requeued = requeue_claimed(work_dir.as_path(), MAX_TRIES);
141    drain_pending(git_dir, handlers, now, None);
142}
143
144/// What:
145///  Runs the records the releases before one service per worktree left in the common directory of a bare repository, from the
146///  instance of a linked worktree: nothing for a repository with a main worktree, for a main worktree and for a directory with
147///  no `commondir`.
148/// Why:
149///   Those releases kept every worktree's records in the common directory. A repository with a main worktree has an instance
150///  whose own directory that is, and it runs them (the main worktree's current records live there too), so a linked instance
151///  must not. A repository whose worktrees are all linked has no such instance, and its pushes would sit there with no notice.
152///   Several linked instances may drain the directory at once, so a claim is taken back only after its lease, which the holder
153///  renews while it works.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// await drainCommonRecords(linkedGitDir, handlers, now)
158/// ```
159pub 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
163/// `drain_common_records` with the lease given, so a test can use a short one.
164pub 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
173/// The common directory a linked worktree's Git directory names in its `commondir` file, or nothing for any other directory.
174fn 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    // Resolved by the text alone, so the result spells the path the way the worktree's own Git directory does, which is how the
181    //  records name their directory.
182    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
197/// Claims and runs every pending item of one work directory until none is left; `lease` is given when other instances drain
198/// the directory too, and the claims held are then renewed so that none of them looks abandoned while it waits or runs.
199fn 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                        // The claim carries the time the round started, which a long round makes most of a lease old; the
211                        //  stamp is brought up to date now instead of at the next renewal.
212                        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                    // A push of the branch went through, so an earlier failure to push it is no longer true. The records go
243                    //  before the items settle: a service that dies between the two pushes again, and nothing is lost.
244                    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                    // The record goes before the items settle, so an item in `failed` always has its record.
251                    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        // Reviews go last: a push is the person's backup, a review is a shadow record, and each is bounded by its budget.
271        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
283/// How long a claim in the common directory of a bare repository goes without a renewal before another linked worktree's
284/// instance runs it again; the holder renews it three times in this span while it works (`service_claim_renewal.rs`).
285pub const SHARED_CLAIM_LEASE: std::time::Duration = std::time::Duration::from_secs(900);
286
287/// What:
288///  Runs one round of wakes: keeps each wake's review credential in memory, drains each named repository once, and
289///  forgets the credentials when the repository is done.
290/// Why:
291///   The credential is held only for the reviews of the round that brought it; a service that restarts before the round
292///  runs has none, and its reviews record the plain "no key" outcome.
293///
294/// In TS you'd write (pseudocode):
295/// ```ts
296/// for (const [dir, key] of wakes) { remember(dir, key); await drain(dir); forget(dir) }
297/// ```
298pub 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
315/// The handlers that run real Git.
316pub struct GitHandlers {
317    /// The context every Git child of the service runs in.
318    pub context: GitContext,
319    /// The review credential of each repository whose wake message carried one. Held in memory only, removed when the
320    /// round that used it ends.
321    review_keys: std::sync::Mutex<std::collections::HashMap<PathBuf, ReviewKey>>,
322    /// Where tests point the review's gateway call, so no test reaches the real endpoint.
323    #[cfg(test)]
324    review_endpoint: Option<String>,
325}
326
327/// Methods over the real handlers.
328impl GitHandlers {
329    /// Handlers that run real Git in `context`, with no review credential yet.
330    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    /// The same handlers with the review's gateway call sent to `endpoint`; tests only.
340    #[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    /// Keep the credential for the reviews of this repository until `forget_review_key`.
347    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    /// Drop the credential of this repository.
352    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
371/// Trait implementation for the real Git.
372impl 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/// Work consumer controls stay out of the release executable.
405#[cfg(test)]
406#[path = "service_work_tests.rs"]
407mod tests;