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::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
31/// The kinds of work the service runs itself.
32pub const SERVICE_KINDS: [WorkKind; 3] = [WorkKind::Push, WorkKind::Maintenance, WorkKind::Review];
33
34/// How many times an item is tried before it is given up on.
35pub const MAX_TRIES: u32 = 3;
36
37/// What a wake message brings: the repository to work on and the review credential that came with it, if any.
38pub type Wake = (PathBuf, Option<ReviewKey>);
39
40/// What a handler came to.
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub enum Verdict {
43    /// The work was done, or there was nothing to do.
44    Done,
45    /// The work failed; the text is what the person should be told.
46    Failed(String),
47}
48
49/// What runs each kind of work.
50pub trait WorkHandlers {
51    /// Push the branch of these items, which share a repository and a branch; the last is the newest.
52    fn push(&self, items: &[WorkItem]) -> Verdict;
53    /// Run the repository's automatic upkeep.
54    fn maintenance(&self, item: &WorkItem) -> Verdict;
55    /// Review one landed commit into the repository's shadow log.
56    fn review(&self, item: &WorkItem) -> Verdict;
57}
58
59/// The work directory of a repository.
60pub fn work_directory(git_dir: &Path) -> PathBuf {
61    return git_dir.join(WORK_DIRECTORY);
62}
63
64/// What:
65///  Whether an item is one this service should act on for this repository.
66/// Why:
67///   Anyone who can write the work directory can write an item, so the fields that become a directory, a reference or a
68///  program argument are checked before they are used.
69///
70/// In TS you'd write (pseudocode):
71/// ```ts
72/// isAcceptable(item, gitDir)
73/// ```
74pub 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
92/// What:
93///  Runs everything pending for one repository: takes back the claims an earlier service left, claims every pending push and maintenance
94///  item, runs one push per branch and one upkeep per repository, settles the items, and leaves a notice for each
95///  failed push.
96/// Why:
97///   Coalescing keeps a burst of commits to one push; a handler that fails leaves the items in `failed`, not in a loop.
98///
99/// In TS you'd write (pseudocode):
100/// ```ts
101/// await drain(gitDir, handlers, new Date())
102/// ```
103pub fn drain(git_dir: &Path, handlers: &dyn WorkHandlers, now: SystemTime) {
104    // This thread claims nothing before this line, and one service serves a worktree at a time, so a claim found in its own
105    //  work directory was left by a service that died or was asked to retire in the middle of a push. Waiting out a lease
106    //  would leave its work stranded for the lease's length; each such death costs the item one try.
107    let _requeued = requeue_claimed(work_directory(git_dir).as_path(), MAX_TRIES);
108    drain_pending(git_dir, handlers, now);
109}
110
111/// What:
112///  Runs the records the releases before one service per worktree left in the common directory, from the instance of a linked
113///  worktree: nothing for a main worktree or a directory with no `commondir`, which drain it as their own.
114/// Why:
115///   Those releases kept every worktree's records in the common directory and the main worktree's instance drains them, but a
116///  repository whose worktrees are all linked (a bare one) has no such instance, and its pushes would sit there with no notice.
117///  Another instance may be running a claim there, so a claim is taken back only after its lease, never at once.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// await drainCommonRecords(linkedGitDir, handlers, now)
122/// ```
123pub 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
132/// The common directory a linked worktree's Git directory names in its `commondir` file, or nothing for any other directory.
133fn 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    // Resolved by the text alone, so the result spells the path the way the worktree's own Git directory does, which is how the
140    //  records name their directory.
141    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
156/// Claims and runs every pending item of one work directory until none is left.
157fn 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                    // A push of the branch went through, so an earlier failure to push it is no longer true. The records go
191                    //  before the items settle: a service that dies between the two pushes again, and nothing is lost.
192                    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                    // The record goes before the items settle, so an item in `failed` always has its record.
199                    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        // Reviews go last: a push is the person's backup, a review is a shadow record, and each is bounded by its budget.
219        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
231/// How long a claim in the common directory is left to its holder before a linked worktree's instance runs it again.
232pub const SHARED_CLAIM_LEASE: std::time::Duration = std::time::Duration::from_secs(900);
233
234/// What:
235///  Runs one round of wakes: keeps each wake's review credential in memory, drains each named repository once, and
236///  forgets the credentials when the repository is done.
237/// Why:
238///   The credential is held only for the reviews of the round that brought it; a service that restarts before the round
239///  runs has none, and its reviews record the plain "no key" outcome.
240///
241/// In TS you'd write (pseudocode):
242/// ```ts
243/// for (const [dir, key] of wakes) { remember(dir, key); await drain(dir); forget(dir) }
244/// ```
245pub 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
262/// The handlers that run real Git.
263pub struct GitHandlers {
264    /// The context every Git child of the service runs in.
265    pub context: GitContext,
266    /// The review credential of each repository whose wake message carried one. Held in memory only, removed when the
267    /// round that used it ends.
268    review_keys: std::sync::Mutex<std::collections::HashMap<PathBuf, ReviewKey>>,
269    /// Where tests point the review's gateway call, so no test reaches the real endpoint.
270    #[cfg(test)]
271    review_endpoint: Option<String>,
272}
273
274/// Methods over the real handlers.
275impl GitHandlers {
276    /// Handlers that run real Git in `context`, with no review credential yet.
277    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    /// The same handlers with the review's gateway call sent to `endpoint`; tests only.
287    #[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    /// Keep the credential for the reviews of this repository until `forget_review_key`.
294    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    /// Drop the credential of this repository.
299    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
318/// Trait implementation for the real Git.
319impl 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/// Work consumer controls stay out of the release executable.
352#[cfg(test)]
353#[path = "service_work_tests.rs"]
354mod tests;