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