1use super::auto_push::push_once;
17use super::auto_push_single_flight::PushGit;
18use super::config_snapshot::read_config_snapshot;
19use super::lfs_pre_push::uses_lfs;
20use super::maintenance_run::{AutoMaintenanceOutcome, run_auto_maintenance_with};
21use super::service_messages::push_failed_notice;
22use super::private_storage::{create_private_directory_all, write_private_file_unsynced};
23use super::service_review::{BUDGET, options_for, review_item};
24use super::transaction_git::GitContext;
25use super::work_item::{DONE, FAILED, WORK_DIRECTORY, WorkItem, WorkKind, claim_next_of, requeue_expired, settle};
26use std::path::{Path, PathBuf};
27use std::time::{Duration, SystemTime};
28
29pub const SERVICE_KINDS: [WorkKind; 3] = [WorkKind::Push, WorkKind::Maintenance, WorkKind::Review];
31
32pub const LEASE: Duration = Duration::from_secs(30 * 60);
35
36pub const MAX_TRIES: u32 = 3;
38
39pub const NOTICE_DIRECTORY: &str = "cli-git/notices";
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(common_dir: &Path) -> PathBuf {
63 return common_dir.join(WORK_DIRECTORY);
64}
65
66pub fn notice_directory(common_dir: &Path) -> PathBuf {
68 return common_dir.join(NOTICE_DIRECTORY);
69}
70
71pub fn is_acceptable(item: &WorkItem, common_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()) == common_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
91pub fn write_notice(common_dir: &Path, id: &str, text: &str) -> Result<(), String> {
93 let directory: PathBuf = notice_directory(common_dir);
94 create_private_directory_all(directory.as_path()).map_err(|error| { return format!("creating the notice directory failed: {error}"); })?;
95 let path: PathBuf = directory.join(format!("{id}.txt"));
96 let _removed = std::fs::remove_file(path.as_path());
98 return write_private_file_unsynced(path.as_path(), text.as_bytes()).map_err(|error| { return format!("writing the notice failed: {error}"); });
99}
100
101pub fn take_notices(common_dir: &Path) -> Vec<String> {
111 let Ok(listing) = std::fs::read_dir(notice_directory(common_dir)) else { return Vec::new() };
112 let mut paths: Vec<PathBuf> = listing.flatten().map(|entry| { return entry.path(); }).collect();
113 paths.sort();
114 let mut texts: Vec<String> = Vec::new();
115 for path in paths {
116 if let Ok(text) = std::fs::read_to_string(path.as_path()) {
117 texts.push(text);
118 }
119 let _removed = std::fs::remove_file(path.as_path());
120 }
121 return texts;
122}
123
124fn settle_all(work_dir: &Path, claimed: &[super::work_item::Claimed], state: &str) {
125 for item in claimed {
126 if let Err(reason) = settle(work_dir, item, state) {
127 eprintln!("cli-git-service: {reason}");
128 }
129 }
130}
131
132pub fn drain(common_dir: &Path, handlers: &dyn WorkHandlers, now: SystemTime) {
144 let work_dir: PathBuf = work_directory(common_dir);
145 let _requeued = requeue_expired(work_dir.as_path(), now, LEASE, MAX_TRIES);
146 loop {
147 let mut claimed: Vec<super::work_item::Claimed> = Vec::new();
148 loop {
149 match claim_next_of(work_dir.as_path(), now, &SERVICE_KINDS) {
150 Ok(Some(item)) => claimed.push(item),
151 Ok(None) => break,
152 Err(reason) => {
153 eprintln!("cli-git-service: {reason}");
154 break;
155 }
156 }
157 }
158 if claimed.is_empty() {
159 return;
160 }
161 let (acceptable, refused): (Vec<_>, Vec<_>) = claimed.into_iter().partition(|item| { return is_acceptable(&item.item, common_dir); });
162 settle_all(work_dir.as_path(), &refused, FAILED);
163 let (pushes, rest): (Vec<_>, Vec<_>) = acceptable.into_iter().partition(|item| { return item.item.kind == WorkKind::Push; });
164 let (reviews, upkeep): (Vec<_>, Vec<_>) = rest.into_iter().partition(|item| { return item.item.kind == WorkKind::Review; });
165 let mut groups: Vec<((String, String), Vec<super::work_item::Claimed>)> = Vec::new();
166 for item in pushes {
167 let key = (item.item.repository.clone(), item.item.branch_ref.clone());
168 match groups.iter_mut().find(|(existing, _)| { return *existing == key; }) {
169 Some((_, members)) => members.push(item),
170 None => groups.push((key, vec![item])),
171 }
172 }
173 for (_, members) in groups {
174 let items: Vec<WorkItem> = members.iter().map(|member| { return member.item.clone(); }).collect();
175 match handlers.push(items.as_slice()) {
176 Verdict::Done => settle_all(work_dir.as_path(), &members, DONE),
177 Verdict::Failed(text) => {
178 settle_all(work_dir.as_path(), &members, FAILED);
179 if let Some(newest) = members.last() {
180 let notice: String = push_failed_notice(newest.item.oid.as_str(), text.as_str());
181 let _noticed = write_notice(common_dir, newest.id.as_str(), notice.as_str());
182 }
183 }
184 }
185 }
186 let mut upkept: Vec<String> = Vec::new();
187 for item in upkeep {
188 if !upkept.contains(&item.item.repository) {
189 upkept.push(item.item.repository.clone());
190 if let Verdict::Failed(reason) = handlers.maintenance(&item.item) {
191 eprintln!("cli-git-service: automatic maintenance did not run: {reason}");
192 }
193 }
194 settle_all(work_dir.as_path(), std::slice::from_ref(&item), DONE);
195 }
196 for item in reviews {
198 match handlers.review(&item.item) {
199 Verdict::Done => settle_all(work_dir.as_path(), std::slice::from_ref(&item), DONE),
200 Verdict::Failed(reason) => {
201 eprintln!("cli-git-service: the review of {} failed: {reason}", item.item.oid);
202 settle_all(work_dir.as_path(), std::slice::from_ref(&item), FAILED);
203 }
204 }
205 }
206 }
207}
208
209pub struct GitHandlers {
211 pub context: GitContext,
213}
214
215impl GitHandlers {
217 fn push_git(&self, item: &WorkItem) -> PushGit {
218 let cwd: PathBuf = PathBuf::from(item.repository.as_str());
219 return PushGit {
220 real_git: self.context.real_git.clone(),
221 lfs: read_config_snapshot(&self.context, cwd.as_path()).as_ref().is_none_or(uses_lfs),
222 cwd,
223 environment: std::env::vars_os().collect(),
224 };
225 }
226}
227
228impl WorkHandlers for GitHandlers {
230 fn push(&self, items: &[WorkItem]) -> Verdict {
231 let Some(newest) = items.last() else { return Verdict::Done };
232 let git: PushGit = self.push_git(newest);
233 let mut output: Vec<u8> = Vec::new();
234 return match push_once(&git, newest.oid.as_str(), &mut output) {
235 Ok(result) if result.exit_code == 0 => Verdict::Done,
236 Ok(result) => Verdict::Failed(result.shown),
237 Err(error) => Verdict::Failed(format!("{error}")),
238 };
239 }
240
241 fn maintenance(&self, item: &WorkItem) -> Verdict {
242 return match run_auto_maintenance_with(&self.context, Path::new(item.repository.as_str()), None) {
243 Ok(AutoMaintenanceOutcome::Exited(code)) if code != 0 => Verdict::Failed(format!("it exited with status {code}")),
244 Ok(_) => Verdict::Done,
245 Err(error) => Verdict::Failed(format!("{error:?}")),
246 };
247 }
248
249 fn review(&self, item: &WorkItem) -> Verdict {
250 return review_item(&self.context, item, options_for(item, &|name| { return std::env::var(name).ok(); }), BUDGET);
251 }
252}
253
254#[cfg(test)]
256#[path = "service_work_tests.rs"]
257mod tests;