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