git_policy_cli/push/
manual_push_range.rs1use crate::auto_push_single_flight::{PushGit, git_command};
15use crate::manual_push_cause::ManualPushCause;
16use crate::manual_push_probe::PushUpdate;
17use crate::start_retry::RetryStart;
19use std::collections::BTreeMap;
21use std::ffi::OsString;
23use std::io::Write;
25use std::process::Stdio;
26
27const TRACKING_NAMESPACE: &str = "refs/remotes/";
29
30#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct ManualPushRangeError {
41 pub message: String,
43 pub cause: ManualPushCause,
45}
46
47impl std::fmt::Display for ManualPushRangeError {
48 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 return formatter.write_str(&self.message);
51 }
52}
53
54impl std::error::Error for ManualPushRangeError {}
55
56fn range_error(cause: ManualPushCause, message: String) -> ManualPushRangeError {
63 return ManualPushRangeError { message, cause };
64}
65
66pub fn run_git_bytes(
77 git: &PushGit,
78 arguments: &[OsString],
79 input: Option<&str>,
80) -> Result<Vec<u8>, ManualPushRangeError> {
81 let mut command = git_command(git, arguments);
82 command
83 .stdin(if input.is_some() {
84 Stdio::piped()
85 } else {
86 Stdio::null()
87 })
88 .stdout(Stdio::piped())
89 .stderr(Stdio::piped());
90 let mut child = command.spawn_retrying().map_err(|error| {
91 return range_error(ManualPushCause::GitNotStarted, format!("git could not be started: {error}"));
92 })?;
93 if let (Some(text), Some(mut stdin)) = (input, child.stdin.take()) {
94 let _written = stdin.write_all(text.as_bytes());
96 }
97 let output = child.wait_with_output().map_err(|error| {
98 return range_error(ManualPushCause::GitSilent, format!("git did not finish: {error}"));
99 })?;
100 if !output.status.success() {
101 return Err(range_error(ManualPushCause::ContentMissing, format!(
102 "git {} failed: {}",
103 arguments
104 .iter()
105 .map(|argument| {
106 return argument.to_string_lossy().into_owned();
107 })
108 .collect::<Vec<String>>()
109 .join(" "),
110 String::from_utf8_lossy(&output.stderr).trim_end()
111 )));
112 }
113 return Ok(output.stdout);
114}
115
116fn lines_of(bytes: &[u8]) -> Vec<String> {
123 return String::from_utf8_lossy(bytes)
124 .split('\n')
125 .filter(|line| {
126 return !line.is_empty();
127 })
128 .map(str::to_owned)
129 .collect();
130}
131
132pub fn peel_commits(
143 git: &PushGit,
144 oids: &[String],
145) -> Result<BTreeMap<String, String>, ManualPushRangeError> {
146 let mut unique: Vec<String> = oids.to_vec();
147 unique.sort();
148 unique.dedup();
149 if unique.is_empty() {
150 return Ok(BTreeMap::new());
151 }
152 let input: String = unique
153 .iter()
154 .map(|oid| {
155 return format!("{oid}^{{}}\n");
156 })
157 .collect();
158 let arguments: Vec<OsString> = ["cat-file", "--batch-check=%(objectname) %(objecttype)"]
159 .iter()
160 .map(OsString::from)
161 .collect();
162 let output = run_git_bytes(git, &arguments, Some(&input))?;
163 let records = lines_of(&output);
164 if records.len() != unique.len() {
165 return Err(range_error(
166 ManualPushCause::UnexpectedOutput,
167 "Git object batch-check returned an unexpected record count.".to_owned(),
168 ));
169 }
170 let mut commits: BTreeMap<String, String> = BTreeMap::new();
171 for (oid, record) in unique.iter().zip(records.iter()) {
172 if record == &format!("{oid}^{{}} missing") {
173 continue;
174 }
175 let fields: Vec<&str> = record.split(' ').collect();
176 if fields.len() != 2 {
177 return Err(range_error(ManualPushCause::UnexpectedOutput, format!(
178 "Git object batch-check returned malformed record: {record}"
179 )));
180 }
181 if fields[1] == "commit" {
182 commits.insert(oid.clone(), fields[0].to_owned());
183 }
184 }
185 return Ok(commits);
186}
187
188pub fn tracking_commits(
198 git: &PushGit,
199 remote_names: &[String],
200) -> Result<BTreeMap<String, Vec<String>>, ManualPushRangeError> {
201 if remote_names.is_empty() {
202 return Ok(BTreeMap::new());
203 }
204 let arguments: Vec<OsString> = [
205 "for-each-ref",
206 "--format=%(objectname) %(objecttype) %(refname)",
207 TRACKING_NAMESPACE,
208 ]
209 .iter()
210 .map(OsString::from)
211 .collect();
212 let refs = lines_of(&run_git_bytes(git, &arguments, None)?);
213 let mut by_remote: BTreeMap<String, Vec<String>> = BTreeMap::new();
214 for remote_name in remote_names {
215 let prefix = format!("{TRACKING_NAMESPACE}{remote_name}/");
216 let mut commits: Vec<String> = Vec::new();
217 for record in &refs {
218 let fields: Vec<&str> = record.split(' ').collect();
219 if fields.len() != 3 {
220 return Err(range_error(ManualPushCause::UnexpectedOutput, format!(
221 "Git for-each-ref returned malformed record: {record}"
222 )));
223 }
224 if fields[1] == "commit" && fields[2].starts_with(&prefix) {
225 commits.push(fields[0].to_owned());
226 }
227 }
228 by_remote.insert(remote_name.clone(), commits);
229 }
230 return Ok(by_remote);
231}
232
233pub fn published_tips(
243 updates: &[PushUpdate],
244 prior_commits: &BTreeMap<String, String>,
245 tracking: &BTreeMap<String, Vec<String>>,
246) -> BTreeMap<String, Vec<String>> {
247 let mut tips: BTreeMap<String, Vec<String>> = BTreeMap::new();
248 for update in updates {
249 let entry = tips.entry(update.remote_name.clone()).or_insert_with(|| {
250 return tracking
251 .get(&update.remote_name)
252 .cloned()
253 .unwrap_or_default();
254 });
255 if let Some(prior) = update.remote_oid.as_ref()
256 && let Some(commit) = prior_commits.get(prior)
257 && !entry.contains(commit)
258 {
259 entry.push(commit.clone());
260 }
261 }
262 return tips;
263}
264
265#[derive(Clone, Debug, Eq, PartialEq)]
276pub enum UpdateScope {
277 None,
279 Commits(Vec<String>),
281 FinalTree(String),
283}
284
285pub fn update_scope(
295 git: &PushGit,
296 update: &PushUpdate,
297 published: &[String],
298) -> Result<UpdateScope, ManualPushRangeError> {
299 let Some(local) = update.local_oid.as_ref() else {
300 return Ok(UpdateScope::None);
301 };
302 let commit_check = peel_commits(git, std::slice::from_ref(local))?;
303 if !commit_check.contains_key(local) {
304 return Err(range_error(ManualPushCause::UnsupportedObject, format!(
305 "pushed object {local} is not a commit, and the manual-push policies read commits only"
306 )));
307 }
308 if published.is_empty() {
309 return Ok(UpdateScope::FinalTree(local.clone()));
310 }
311 let mut arguments: Vec<OsString> = vec![
312 OsString::from("rev-list"),
313 OsString::from(local),
314 OsString::from("--not"),
315 ];
316 arguments.extend(published.iter().map(OsString::from));
317 let commits = lines_of(&run_git_bytes(git, &arguments, None)?);
318 return Ok(UpdateScope::Commits(commits));
319}
320
321#[cfg(test)]
323#[path = "manual_push_range_tests.rs"]
324mod tests;