1use super::auto_push_single_flight::{PushGit, run_query};
19use super::lfs_upload_plan::resolve_push_remote;
21use super::manual_push_porcelain::{PorcelainUpdate, RemoteBefore, parse_porcelain};
22use super::command_push::push_shape;
24use std::ffi::OsString;
26
27#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct ManualPushProbeError {
38 pub message: String,
40}
41
42impl std::fmt::Display for ManualPushProbeError {
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 ManualPushProbeError {}
50
51fn probe_error(message: String) -> ManualPushProbeError {
58 return ManualPushProbeError { message };
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct PushUpdate {
72 pub local_oid: Option<String>,
74 pub remote_oid: Option<String>,
76 pub remote_name: String,
78 pub remote_ref: String,
80}
81
82pub fn probe_arguments(global_prefix: &[OsString], post_subcommand: &[OsString]) -> Vec<OsString> {
95 let separator = post_subcommand.iter().position(|argument| {
96 return argument.as_os_str() == "--";
97 });
98 let insertion = separator.unwrap_or(post_subcommand.len());
99 let mut arguments: Vec<OsString> = global_prefix.to_vec();
100 for setting in [
101 format!("core.hooksPath={}", super::hooks_off::NO_HOOKS_DIRECTORY),
102 String::from("hook.pre-push.enabled=false"),
103 String::from("core.abbrev=no"),
104 ] {
105 arguments.push(OsString::from("-c"));
106 arguments.push(OsString::from(setting));
107 }
108 arguments.push(OsString::from("push"));
109 arguments.extend(post_subcommand[..insertion].iter().cloned());
110 arguments.push(OsString::from("--no-quiet"));
113 arguments.push(OsString::from("--dry-run"));
114 arguments.push(OsString::from("--porcelain"));
115 arguments.extend(post_subcommand[insertion..].iter().cloned());
116 return arguments;
117}
118
119fn looks_like_location(typed: &str) -> bool {
121 return typed.contains("://")
122 || typed.starts_with('/')
123 || typed.starts_with("./")
124 || typed.starts_with("../")
125 || typed.split_once(':').is_some_and(|(front, _)| {
126 return !front.contains('/');
127 });
128}
129
130fn answer(git: &PushGit, arguments: &[OsString]) -> Option<String> {
132 let output = run_query(git, arguments).ok()?;
133 if output.code != Some(0) {
134 return None;
135 }
136 return Some(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_owned());
137}
138
139fn locations_of(git: &PushGit, global_prefix: &[OsString], remote: &str) -> Vec<String> {
142 let mut arguments: Vec<OsString> = global_prefix.to_vec();
143 arguments.extend(["remote", "get-url", "--push", "--all", remote].map(OsString::from));
144 return match answer(git, arguments.as_slice()) {
145 Some(listing) if !listing.is_empty() => listing.lines().map(str::to_owned).collect(),
146 Some(_) | None => vec![remote.to_owned()],
147 };
148}
149
150fn local_objects(git: &PushGit, global_prefix: &[OsString], names: &[&str]) -> Result<Vec<String>, ManualPushProbeError> {
160 if names.is_empty() {
161 return Ok(Vec::new());
162 }
163 let mut arguments: Vec<OsString> = global_prefix.to_vec();
164 arguments.push(OsString::from("rev-parse"));
165 arguments.extend(names.iter().map(OsString::from));
166 let listing: String = answer(git, arguments.as_slice())
167 .ok_or_else(|| {
168 return probe_error(format!("the local objects of {names:?} could not be read"));
169 })?;
170 let objects: Vec<String> = listing.lines().map(str::to_owned).collect();
171 if objects.len() != names.len() {
172 return Err(probe_error(format!("rev-parse answered {} names for {}", objects.len(), names.len())));
173 }
174 return Ok(objects);
175}
176
177pub fn confirm_group(
189 git: &PushGit,
190 remote_location: &str,
191 remote_name: &str,
192 updates: &[(PorcelainUpdate, Option<String>)],
193) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
194 let mut arguments: Vec<OsString> = vec![
195 OsString::from("ls-remote"),
196 OsString::from("--refs"),
197 OsString::from(remote_location),
198 ];
199 arguments.extend(updates.iter().map(|(update, _local)| {
200 return OsString::from(&update.to);
201 }));
202 let output = run_query(git, arguments.as_slice())
203 .map_err(|error| {
204 return probe_error(format!("ls-remote of {remote_location} failed: {error}"));
205 })?;
206 if output.code != Some(0) {
207 return Err(probe_error(format!(
208 "ls-remote of {remote_location} failed: {}",
209 String::from_utf8_lossy(&output.stderr).trim_end()
210 )));
211 }
212 let listing: String = String::from_utf8_lossy(&output.stdout).into_owned();
213 let mut confirmed: Vec<PushUpdate> = Vec::new();
214 for (update, local) in updates {
215 let authoritative: Option<String> = listing.lines().find_map(|line| {
216 return line.split_once('\t').filter(|(_oid, name)| {
217 return *name == update.to;
218 }).map(|(oid, _name)| {
219 return oid.to_owned();
220 });
221 });
222 let changed: bool = match &update.before {
223 RemoteBefore::Absent => authoritative.is_some(),
224 RemoteBefore::Known(reported) => authoritative.as_ref() != Some(reported),
225 RemoteBefore::Unknown => false,
226 };
227 if changed {
228 return Err(probe_error(format!("Remote ref changed during manual-push discovery: {}", update.to)));
229 }
230 confirmed.push(PushUpdate {
231 local_oid: local.clone(),
232 remote_oid: authoritative,
233 remote_name: remote_name.to_owned(),
234 remote_ref: update.to.clone(),
235 });
236 }
237 return Ok(confirmed);
238}
239
240pub fn probe_manual_push_updates(
250 git: &PushGit,
251 global_prefix: &[OsString],
252 post_subcommand: &[OsString],
253) -> Result<Vec<PushUpdate>, ManualPushProbeError> {
254 let arguments: Vec<OsString> = probe_arguments(global_prefix, post_subcommand);
255 let output = run_query(git, arguments.as_slice()).map_err(|error| {
256 return probe_error(format!("probe push could not run: {error}"));
257 })?;
258 let report: String = String::from_utf8_lossy(output.stdout.as_slice()).into_owned();
259 let blocks = parse_porcelain(report.as_str()).map_err(probe_error)?;
260 if blocks.is_empty() {
261 if output.code == Some(0) {
262 return Ok(Vec::new());
263 }
264 return Err(probe_error(format!(
265 "Git could not determine push updates: {}",
266 String::from_utf8_lossy(&output.stderr).trim_end()
267 )));
268 }
269 let named = push_shape(post_subcommand).and_then(|shape| {
270 return shape.repository;
271 });
272 let typed_location: Option<String> = named.as_ref().map(|typed| {
273 return typed.to_string_lossy().into_owned();
274 });
275 let mut asking = |query: &[&str]| -> Result<String, String> {
276 let mut owned: Vec<OsString> = global_prefix.to_vec();
277 owned.extend(query.iter().map(OsString::from));
278 let answered = run_query(git, owned.as_slice()).map_err(|error| {
279 return error.to_string();
280 })?;
281 let text: String = String::from_utf8_lossy(answered.stdout.as_slice()).trim().to_owned();
282 return match answered.code {
283 Some(0) => Ok(text),
284 Some(1) if text.is_empty() => Ok(text),
285 code => Err(format!("exited with {code:?}")),
286 };
287 };
288 let remote_name: String = resolve_push_remote(named, &mut asking)
289 .map_err(|error| {
290 return probe_error(error.0);
291 })?
292 .to_string_lossy()
293 .into_owned();
294 let locations: Vec<String> = match typed_location {
297 Some(typed) if looks_like_location(typed.as_str()) => vec![typed],
298 _ => locations_of(git, global_prefix, remote_name.as_str()),
299 };
300 if locations.len() != blocks.len() {
301 return Err(probe_error(format!(
302 "Git reported {} destinations but {remote_name} has {} push locations",
303 blocks.len(),
304 locations.len()
305 )));
306 }
307 let mut confirmed: Vec<PushUpdate> = Vec::new();
308 for (block, location) in blocks.iter().zip(locations.iter()) {
309 let unnamed: Vec<&str> = block
310 .updates
311 .iter()
312 .filter(|update| {
313 return !update.from.is_empty() && update.after.is_none();
314 })
315 .map(|update| {
316 return update.from.as_str();
317 })
318 .collect();
319 let mut resolved = local_objects(git, global_prefix, unnamed.as_slice())?.into_iter();
320 let group: Vec<(PorcelainUpdate, Option<String>)> = block
321 .updates
322 .iter()
323 .map(|update| {
324 let local: Option<String> = if update.from.is_empty() {
325 None
326 } else {
327 update.after.clone().or_else(|| {
328 return resolved.next();
329 })
330 };
331 return (update.clone(), local);
332 })
333 .collect();
334 confirmed.extend(confirm_group(git, location.as_str(), remote_name.as_str(), group.as_slice())?);
335 }
336 return Ok(confirmed);
337}
338
339#[cfg(test)]
341#[path = "manual_push_probe_tests.rs"]
342mod tests;