1use std::collections::HashSet;
12use std::path::Path;
13
14use thiserror::Error;
15
16use crate::branch_tracking::{apply_push_set_upstream, remote_tracking_ref_for_remote_ref};
17use crate::config::{parse_git_config_maybe_bool, ConfigSet};
18use crate::merge_base::is_ancestor;
19use crate::objects::{ObjectId, ObjectKind};
20use crate::push_report::PushRefResult;
21use crate::refs;
22use crate::refspec::{parse_push_refspec, RefspecItem};
23use crate::remote::{HttpClientFactory, Remote, RemoteError, RemoteRef};
24use crate::repo::Repository;
25use crate::repo_handles::has_remote_section;
26use crate::rev_parse::{resolve_push_full_ref_for_branch, resolve_revision_for_verify};
27use crate::state::{resolve_head, HeadState};
28use crate::transfer::{resolve_push_delete_ref, PushRefSpec};
29
30pub use crate::remote::DEFAULT_REMOTE;
31
32#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub enum ForceMode {
35 #[default]
37 None,
38 Force,
40 Lease(LeaseExpectation),
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum LeaseExpectation {
47 Tracking,
49 Value(ObjectId),
51}
52
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
55pub struct PushRequest {
56 pub remote: Option<String>,
58 pub refspecs: Vec<String>,
60 pub tags: bool,
62 pub delete: Vec<String>,
64 pub force: ForceMode,
66 pub follow_tags: bool,
68 pub set_upstream: bool,
70}
71
72#[derive(Debug, Clone)]
74pub struct ResolvedPush {
75 pub remote: String,
77 pub specs: Vec<PushRefSpec>,
79 pub branch_label: Option<String>,
81 pub kind: PushPlanKind,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum PushPlanKind {
88 Branch,
90 Tags,
92 Delete,
94 Refspecs,
96}
97
98#[derive(Debug, Clone, Default)]
100pub struct PushPlanContext {
101 pub remote_ref_names: HashSet<String>,
103 pub remote_branch_tips: HashSet<ObjectId>,
106 pub remote_tips: Option<Vec<ObjectId>>,
113}
114
115#[derive(Debug, Error, PartialEq, Eq)]
117pub enum PushPlanError {
118 #[error("HEAD is detached; specify a refspec (for example HEAD:refs/heads/<name>)")]
120 DetachedHeadNoRefspec,
121 #[error("no commits yet to push")]
123 NoCommits,
124 #[error("no tags to push")]
126 NoTags,
127 #[error("push.default is nothing; no push destination")]
129 PushDefaultNothing,
130 #[error("no remote to push to")]
133 NoRemote,
134 #[error("unknown remote '{0}'")]
136 UnknownRemote(String),
137 #[error("invalid refspec '{spec}': {reason}")]
139 InvalidRefspec {
140 spec: String,
142 reason: String,
144 },
145 #[error("src refspec {spec} does not match any ref")]
147 SrcRefspecNoMatch {
148 spec: String,
150 },
151 #[error("src refspec {spec} matches more than one ref")]
153 SrcRefspecAmbiguous {
154 spec: String,
156 },
157 #[error("dst refspec {spec} matches more than one remote ref")]
159 DstRefspecAmbiguous {
160 spec: String,
162 },
163 #[error("could not resolve revision '{spec}'")]
165 UnknownRevision {
166 spec: String,
168 },
169 #[error("--force-with-lease=<rev> requires pushing exactly one ref")]
171 LeaseValueRequiresSingleRef,
172 #[error("{0}")]
174 Message(String),
175}
176
177type PlanResult<T> = std::result::Result<T, PushPlanError>;
178
179pub fn parse_force_mode(
181 repo: &Repository,
182 force: bool,
183 force_with_lease: Option<Option<String>>,
184) -> PlanResult<ForceMode> {
185 if force {
186 return Ok(ForceMode::Force);
187 }
188 let Some(flag) = force_with_lease else {
189 return Ok(ForceMode::None);
190 };
191 match flag {
192 None => Ok(ForceMode::Lease(LeaseExpectation::Tracking)),
193 Some(rev) => {
194 let oid = crate::rev_parse::resolve_revision_for_verify(repo, &rev)
195 .map_err(|_| PushPlanError::UnknownRevision { spec: rev.clone() })?;
196 Ok(ForceMode::Lease(LeaseExpectation::Value(oid)))
197 }
198 }
199}
200
201pub fn resolve_push(
206 repo: &Repository,
207 config: &ConfigSet,
208 request: &PushRequest,
209 ctx: &PushPlanContext,
210) -> PlanResult<ResolvedPush> {
211 if !request.delete.is_empty() {
212 return resolve_delete_push(repo, config, request, ctx);
213 }
214 if request.tags {
215 return resolve_tags_push(repo, config, request);
216 }
217
218 let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
219 validate_remote(config, &remote)?;
220
221 let follow_tags = request.follow_tags || push_follow_tags_config(config);
222
223 let mut resolved = if request.refspecs.is_empty() {
224 resolve_default_push(repo, config, &remote, ctx)?
225 } else {
226 let mut specs = Vec::new();
227 for spec in &request.refspecs {
228 specs.extend(resolve_cli_refspec(repo, config, &remote, spec, ctx)?);
229 }
230 let branch_label = branch_label_from_specs(repo, &specs);
231 ResolvedPush {
232 remote,
233 specs,
234 branch_label,
235 kind: PushPlanKind::Refspecs,
236 }
237 };
238
239 if follow_tags {
240 let extra = follow_tag_push_specs(repo, &resolved.specs, ctx)?;
241 resolved.specs.extend(extra);
242 }
243
244 apply_force_mode(
245 repo,
246 config,
247 &resolved.remote,
248 &mut resolved.specs,
249 &request.force,
250 )?;
251 Ok(resolved)
252}
253
254#[must_use]
256pub fn push_follow_tags_config(config: &ConfigSet) -> bool {
257 config
258 .get("push.followTags")
259 .or_else(|| config.get("push.followtags"))
260 .and_then(|v| parse_git_config_maybe_bool(&v))
261 .unwrap_or(false)
262}
263
264pub fn follow_tag_push_specs(
270 repo: &Repository,
271 specs: &[PushRefSpec],
272 ctx: &PushPlanContext,
273) -> PlanResult<Vec<PushRefSpec>> {
274 let tips = push_follow_tip_commits(repo, specs)?;
275 if tips.is_empty() {
276 return Ok(Vec::new());
277 }
278
279 let existing: HashSet<&str> = specs.iter().map(|s| s.dst.as_str()).collect();
280 let odb = repo.odb();
281 let mut out = Vec::new();
282 for (tag_name, tag_oid) in list_local_refs(repo.git_dir(), "refs/tags/")? {
283 if ctx.remote_ref_names.contains(&tag_name) || existing.contains(tag_name.as_str()) {
284 continue;
285 }
286 let Ok(obj) = odb.read(&tag_oid) else {
287 continue;
288 };
289 if obj.kind != ObjectKind::Tag {
290 continue;
291 }
292 let Ok((peeled, _)) = odb.peel_tags(tag_oid) else {
293 continue;
294 };
295 if !tag_reachable_from_push_tips(repo, peeled, &tips)? {
296 continue;
297 }
298 out.push(push_spec(
299 Some(tag_oid),
300 Some(tag_name.clone()),
301 tag_name,
302 false,
303 false,
304 ));
305 }
306 Ok(out)
307}
308
309pub fn apply_set_upstream_after_push(
314 repo: &Repository,
315 remote: &str,
316 set_upstream: bool,
317 dry_run: bool,
318 specs: &[PushRefSpec],
319 results: &[PushRefResult],
320) -> Result<Vec<String>, crate::error::Error> {
321 if !set_upstream || dry_run {
322 return Ok(Vec::new());
323 }
324 apply_push_set_upstream(repo, remote, specs, results)
325}
326
327fn push_follow_tip_commits(repo: &Repository, specs: &[PushRefSpec]) -> PlanResult<Vec<ObjectId>> {
328 let mut tips = Vec::new();
329 for spec in specs {
330 if spec.delete || is_tag_push_spec(repo, spec) {
331 continue;
332 }
333 if let Some(commit) = peel_push_src_to_commit(repo, spec) {
334 tips.push(commit);
335 }
336 }
337 Ok(tips)
338}
339
340fn is_tag_push_spec(repo: &Repository, spec: &PushRefSpec) -> bool {
341 if spec.dst.starts_with("refs/tags/") {
342 return true;
343 }
344 let Some(src) = spec.src else {
345 return false;
346 };
347 let Ok(obj) = repo.odb().read(&src) else {
348 return false;
349 };
350 obj.kind == ObjectKind::Tag
351}
352
353fn peel_push_src_to_commit(repo: &Repository, spec: &PushRefSpec) -> Option<ObjectId> {
354 let src = spec.src?;
355 let odb = repo.odb();
356 let Ok(obj) = odb.read(&src) else {
357 return None;
358 };
359 match obj.kind {
360 ObjectKind::Commit => Some(src),
361 ObjectKind::Tag => odb.peel_tags(src).ok().map(|(commit, _)| commit),
362 _ => None,
363 }
364}
365
366fn tag_reachable_from_push_tips(
367 repo: &Repository,
368 peeled: ObjectId,
369 tips: &[ObjectId],
370) -> PlanResult<bool> {
371 for &tip in tips {
372 if peeled == tip {
373 return Ok(true);
374 }
375 if is_ancestor(repo, peeled, tip).map_err(|e| PushPlanError::Message(e.to_string()))? {
376 return Ok(true);
377 }
378 }
379 Ok(false)
380}
381
382fn resolve_delete_push(
383 repo: &Repository,
384 config: &ConfigSet,
385 request: &PushRequest,
386 ctx: &PushPlanContext,
387) -> PlanResult<ResolvedPush> {
388 let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
389 validate_remote(config, &remote)?;
390 let specs = request
391 .delete
392 .iter()
393 .map(|name| {
394 let dst = resolve_push_delete_ref(repo.git_dir(), &ctx.remote_ref_names, name);
395 Ok(push_spec(None, None, dst, false, true))
396 })
397 .collect::<PlanResult<Vec<_>>>()?;
398 let branch_label = delete_branch_label(&specs);
399 let mut resolved = ResolvedPush {
400 remote,
401 specs,
402 branch_label,
403 kind: PushPlanKind::Delete,
404 };
405 apply_force_mode(
406 repo,
407 config,
408 &resolved.remote,
409 &mut resolved.specs,
410 &request.force,
411 )?;
412 Ok(resolved)
413}
414
415fn resolve_tags_push(
416 repo: &Repository,
417 config: &ConfigSet,
418 request: &PushRequest,
419) -> PlanResult<ResolvedPush> {
420 let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
421 validate_remote(config, &remote)?;
422 let entries = refs::list_refs(repo.git_dir(), "refs/tags/")
423 .map_err(|e| PushPlanError::Message(e.to_string()))?;
424 if entries.is_empty() {
425 return Err(PushPlanError::NoTags);
426 }
427 let specs = entries
428 .into_iter()
429 .map(|(refname, oid)| push_spec(Some(oid), Some(refname.clone()), refname, false, false))
430 .collect();
431 let mut resolved = ResolvedPush {
432 remote,
433 specs,
434 branch_label: None,
435 kind: PushPlanKind::Tags,
436 };
437 apply_force_mode(
438 repo,
439 config,
440 &resolved.remote,
441 &mut resolved.specs,
442 &request.force,
443 )?;
444 Ok(resolved)
445}
446
447fn resolve_default_push(
448 repo: &Repository,
449 config: &ConfigSet,
450 remote: &str,
451 ctx: &PushPlanContext,
452) -> PlanResult<ResolvedPush> {
453 let push_specs = remote_push_refspec_strings(config, remote);
454 if !push_specs.is_empty() {
455 let mut positive_specs = Vec::new();
456 let mut negative_items = Vec::new();
457 for spec in push_specs {
458 let item = parse_push_refspec(&spec).map_err(|e| PushPlanError::InvalidRefspec {
459 spec: spec.clone(),
460 reason: e.to_string(),
461 })?;
462 if item.negative {
463 negative_items.push(item);
464 } else {
465 positive_specs.push(spec);
466 }
467 }
468 let mut specs = Vec::new();
469 for spec in positive_specs {
470 specs.extend(expand_configured_push_refspec(repo, spec, ctx)?);
471 }
472 for item in negative_items {
473 specs.retain(|s| !push_spec_excluded_by_negative(&item, s));
474 }
475 let branch_label = branch_label_from_specs(repo, &specs);
476 let kind = if specs.len() == 1 {
477 PushPlanKind::Branch
478 } else {
479 PushPlanKind::Refspecs
480 };
481 return Ok(ResolvedPush {
482 remote: remote.to_owned(),
483 specs,
484 branch_label,
485 kind,
486 });
487 }
488
489 let push_default = config
490 .get("push.default")
491 .unwrap_or_else(|| "simple".to_owned());
492 if push_default == "matching" {
493 return resolve_matching_default_push(repo, remote, ctx);
494 }
495
496 let (short_name, oid) = current_branch_oid(repo)?;
497 let src_ref = format!("refs/heads/{short_name}");
498 let dst = default_push_destination(repo, config, remote, &short_name, &src_ref)?;
499 let spec = push_spec(Some(oid), Some(src_ref.clone()), dst, false, false);
500 Ok(ResolvedPush {
501 remote: remote.to_owned(),
502 specs: vec![spec],
503 branch_label: Some(short_name),
504 kind: PushPlanKind::Branch,
505 })
506}
507
508fn resolve_matching_default_push(
510 repo: &Repository,
511 remote: &str,
512 ctx: &PushPlanContext,
513) -> PlanResult<ResolvedPush> {
514 let heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
515 let mut specs = Vec::new();
516 for (name, oid) in heads {
517 if ctx.remote_ref_names.contains(&name) {
518 specs.push(push_spec(
519 Some(oid),
520 Some(name.clone()),
521 name.clone(),
522 false,
523 false,
524 ));
525 }
526 }
527 let branch_label = branch_label_from_specs(repo, &specs);
528 let kind = if specs.len() == 1 {
529 PushPlanKind::Branch
530 } else {
531 PushPlanKind::Refspecs
532 };
533 Ok(ResolvedPush {
534 remote: remote.to_owned(),
535 specs,
536 branch_label,
537 kind,
538 })
539}
540
541fn current_branch_oid(repo: &Repository) -> PlanResult<(String, ObjectId)> {
542 match resolve_head(repo.git_dir()).map_err(|e| PushPlanError::Message(e.to_string()))? {
543 HeadState::Branch {
544 short_name,
545 oid: Some(oid),
546 ..
547 } => Ok((short_name, oid)),
548 HeadState::Branch { .. } => Err(PushPlanError::NoCommits),
549 HeadState::Detached { .. } => Err(PushPlanError::DetachedHeadNoRefspec),
550 HeadState::Invalid => Err(PushPlanError::Message("HEAD is in an unknown state".into())),
551 }
552}
553
554pub fn configured_remote_for_push(
562 repo: &Repository,
563 config: &ConfigSet,
564 remote_override: Option<&str>,
565) -> PlanResult<String> {
566 if let Some(remote) = remote_override.filter(|r| !r.trim().is_empty()) {
567 return Ok(remote.to_owned());
568 }
569 if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
570 if let Some(remote) = config
571 .get(&format!("branch.{short_name}.pushremote"))
572 .or_else(|| config.get(&format!("branch.{short_name}.pushRemote")))
573 .filter(|r| !r.trim().is_empty())
574 {
575 return Ok(remote);
576 }
577 }
578 if let Some(remote) = config
579 .get("remote.pushdefault")
580 .or_else(|| config.get("remote.pushDefault"))
581 .filter(|r| !r.trim().is_empty())
582 {
583 return Ok(remote);
584 }
585 if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
586 if let Some(remote) = config
587 .get(&format!("branch.{short_name}.remote"))
588 .filter(|r| !r.trim().is_empty() && r != ".")
589 {
590 return Ok(remote);
591 }
592 }
593 if !has_remote_section(config, DEFAULT_REMOTE) {
594 return Err(PushPlanError::NoRemote);
595 }
596 Ok(DEFAULT_REMOTE.to_owned())
597}
598
599#[derive(Debug, Clone, Default, PartialEq, Eq)]
601pub struct PushInvocation {
602 pub remote: Option<String>,
604 pub refspecs: Vec<String>,
606}
607
608pub fn resolve_push_invocation(
620 config: &ConfigSet,
621 remote_flag: Option<&str>,
622 positionals: &[String],
623) -> PlanResult<PushInvocation> {
624 if let Some(remote) = remote_flag {
625 return Ok(PushInvocation {
626 remote: Some(remote.to_owned()),
627 refspecs: positionals.to_vec(),
628 });
629 }
630 let Some((first, rest)) = positionals.split_first() else {
631 return Ok(PushInvocation::default());
632 };
633 if has_remote_url(config, first) || looks_like_literal_remote(first) {
634 return Ok(PushInvocation {
635 remote: Some(first.clone()),
636 refspecs: rest.to_vec(),
637 });
638 }
639 if !rest.is_empty() {
640 return Err(PushPlanError::UnknownRemote(first.clone()));
641 }
642 Ok(PushInvocation {
643 remote: None,
644 refspecs: positionals.to_vec(),
645 })
646}
647
648pub fn open_push_destination(config: &ConfigSet, name: &str) -> Result<Remote, RemoteError> {
657 if looks_like_literal_remote(name) {
658 Remote::from_url(name)
659 } else {
660 Remote::from_config(config, name)
661 }
662}
663
664impl PushPlanContext {
665 #[must_use]
667 pub fn from_advertised_refs(refs: impl IntoIterator<Item = RemoteRef>) -> Self {
668 let mut ctx = Self::default();
669 let mut tips = Vec::new();
670 for remote_ref in refs {
671 if remote_ref.peeled_tag().is_some() {
672 continue;
673 }
674 if remote_ref.name.starts_with("refs/heads/") {
675 ctx.remote_branch_tips.insert(remote_ref.oid);
676 }
677 tips.push(remote_ref.oid);
678 ctx.remote_ref_names.insert(remote_ref.name);
679 }
680 ctx.remote_tips = Some(tips);
681 ctx
682 }
683}
684
685pub fn load_push_plan_context(
696 repo: &Repository,
697 config: &ConfigSet,
698 remote_override: Option<&str>,
699 http_factory: Option<&dyn HttpClientFactory>,
700) -> PlanResult<PushPlanContext> {
701 let remote = configured_remote_for_push(repo, config, remote_override)?;
702 let Ok(handle) = open_push_destination(config, &remote) else {
703 return Ok(PushPlanContext::default());
704 };
705 let Ok(advertised) = handle.list_push_refs(repo, http_factory) else {
706 return Ok(PushPlanContext::default());
707 };
708 Ok(PushPlanContext::from_advertised_refs(advertised))
709}
710
711fn looks_like_literal_remote(name: &str) -> bool {
712 name.contains("://") || name.ends_with(".git") || name.starts_with('/')
713}
714
715fn has_remote_url(config: &ConfigSet, name: &str) -> bool {
716 config
717 .get(&format!("remote.{name}.url"))
718 .is_some_and(|u| !u.trim().is_empty())
719}
720
721fn validate_remote(config: &ConfigSet, name: &str) -> PlanResult<()> {
722 if has_remote_url(config, name) {
723 Ok(())
724 } else if name == DEFAULT_REMOTE || looks_like_literal_remote(name) {
725 Ok(())
727 } else {
728 Err(PushPlanError::UnknownRemote(name.to_owned()))
729 }
730}
731
732fn remote_push_refspec_strings(config: &ConfigSet, remote: &str) -> Vec<String> {
733 config
734 .get_all(&format!("remote.{remote}.push"))
735 .into_iter()
736 .filter(|s| !s.trim().is_empty())
737 .collect()
738}
739
740fn default_push_destination(
741 repo: &Repository,
742 config: &ConfigSet,
743 remote: &str,
744 branch_short: &str,
745 _src_ref: &str,
746) -> PlanResult<String> {
747 let push_default = config
748 .get("push.default")
749 .unwrap_or_else(|| "simple".to_owned());
750
751 if push_default == "nothing" {
752 return Err(PushPlanError::PushDefaultNothing);
753 }
754
755 if push_default.as_str() == "upstream" {
756 let merge = config
757 .get(&format!("branch.{branch_short}.merge"))
758 .filter(|m| m.starts_with("refs/"));
759 return merge.ok_or_else(|| {
760 PushPlanError::Message(format!(
761 "branch '{branch_short}' has no upstream for push.default upstream"
762 ))
763 });
764 }
765
766 if push_default.as_str() == "simple" {
769 let branch_remote = config.get(&format!("branch.{branch_short}.remote"));
770 if branch_remote.as_deref() == Some(remote) {
771 if let Some(merge) = config
772 .get(&format!("branch.{branch_short}.merge"))
773 .filter(|m| m.starts_with("refs/"))
774 {
775 let _merge_branch = merge.strip_prefix("refs/heads/").unwrap_or(merge.as_str());
776 let same_name_dst = format!("refs/heads/{branch_short}");
777 if merge != same_name_dst {
778 return Ok(merge);
780 }
781 return Ok(same_name_dst);
782 }
783 }
784 }
785
786 let _ = resolve_push_full_ref_for_branch(repo, branch_short);
787 Ok(format!("refs/heads/{branch_short}"))
788}
789
790fn resolve_cli_refspec(
791 repo: &Repository,
792 config: &ConfigSet,
793 remote: &str,
794 spec: &str,
795 ctx: &PushPlanContext,
796) -> PlanResult<Vec<PushRefSpec>> {
797 if !spec.contains(':') {
798 return resolve_shorthand_refspec(repo, config, remote, spec, ctx);
799 }
800 let item = parse_push_refspec(spec).map_err(|e| PushPlanError::InvalidRefspec {
801 spec: spec.to_owned(),
802 reason: e.to_string(),
803 })?;
804 resolve_parsed_refspec(repo, &item, ctx)
805}
806
807fn resolve_shorthand_refspec(
808 repo: &Repository,
809 config: &ConfigSet,
810 remote: &str,
811 spec: &str,
812 ctx: &PushPlanContext,
813) -> PlanResult<Vec<PushRefSpec>> {
814 let local_heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
815 let local_tags = list_local_refs(repo.git_dir(), "refs/tags/")?;
816 let mut all = local_heads;
817 all.extend(local_tags);
818
819 let count = count_refname_match(spec, &all);
820 if count == 1 {
821 let Some(matched) = refname_match(spec, &all) else {
822 return Err(PushPlanError::SrcRefspecNoMatch {
823 spec: spec.to_owned(),
824 });
825 };
826 if let Some(mapped) = map_through_remote_push_refspec(config, remote, &matched.0) {
827 let item = parse_push_refspec(&mapped).map_err(|e| PushPlanError::InvalidRefspec {
828 spec: mapped.clone(),
829 reason: e.to_string(),
830 })?;
831 return resolve_parsed_refspec(repo, &item, ctx);
832 }
833 let item = RefspecItem {
834 force: false,
835 src: Some(matched.0.clone()),
836 dst: Some(matched.0.clone()),
837 ..Default::default()
838 };
839 return resolve_parsed_refspec(repo, &item, ctx);
840 }
841 if count == 0 {
842 let item = parse_push_refspec(spec).map_err(|e| PushPlanError::InvalidRefspec {
844 spec: spec.to_owned(),
845 reason: e.to_string(),
846 })?;
847 return resolve_parsed_refspec(repo, &item, ctx);
848 }
849 Err(PushPlanError::SrcRefspecAmbiguous {
850 spec: spec.to_owned(),
851 })
852}
853
854fn map_through_remote_push_refspec(
855 config: &ConfigSet,
856 remote: &str,
857 local_full_ref: &str,
858) -> Option<String> {
859 for spec in remote_push_refspec_strings(config, remote) {
860 let Ok(item) = parse_push_refspec(&spec) else {
861 continue;
862 };
863 if item.negative || item.matching {
864 continue;
865 }
866 if let Some(dst) = refspec_map_src(&item, local_full_ref) {
867 let force = if item.force { "+" } else { "" };
868 return Some(format!("{force}{local_full_ref}:{dst}"));
869 }
870 }
871 None
872}
873
874fn expand_configured_push_refspec(
875 repo: &Repository,
876 spec: String,
877 ctx: &PushPlanContext,
878) -> PlanResult<Vec<PushRefSpec>> {
879 let item = parse_push_refspec(&spec).map_err(|e| PushPlanError::InvalidRefspec {
880 spec: spec.clone(),
881 reason: e.to_string(),
882 })?;
883 if item.matching {
884 let heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
885 let mut out = Vec::new();
886 for (name, oid) in heads {
887 if !ctx.remote_ref_names.contains(&name) {
888 continue;
889 }
890 out.push(push_spec(
891 Some(oid),
892 Some(name.clone()),
893 name,
894 item.force,
895 false,
896 ));
897 }
898 return Ok(out);
899 }
900 if item.pattern {
901 let prefix = item.src.as_deref().unwrap_or("");
902 let local = if prefix.starts_with("refs/tags/") {
903 list_local_refs(repo.git_dir(), "refs/tags/")?
904 } else {
905 list_local_refs(repo.git_dir(), "refs/heads/")?
906 };
907 let mut out = Vec::new();
908 for (name, _) in local {
909 if let Some(dst) = refspec_map_src(&item, &name) {
910 let oid = resolve_src_ref(repo, &name)?;
911 out.push(push_spec(
912 Some(oid),
913 Some(name.clone()),
914 dst,
915 item.force,
916 false,
917 ));
918 }
919 }
920 return Ok(out);
921 }
922 resolve_parsed_refspec(repo, &item, ctx)
923}
924
925fn resolve_parsed_refspec(
926 repo: &Repository,
927 item: &RefspecItem,
928 ctx: &PushPlanContext,
929) -> PlanResult<Vec<PushRefSpec>> {
930 if item.negative {
931 return Ok(Vec::new());
932 }
933 if item.matching {
934 return expand_configured_push_refspec(
935 repo,
936 if item.force {
937 "+:".to_owned()
938 } else {
939 ":".to_owned()
940 },
941 ctx,
942 );
943 }
944
945 let src_empty = item.src.as_deref().is_some_and(|s| s.is_empty());
946 let src_name = item.src.as_deref().unwrap_or("");
947
948 if src_empty || (src_name.is_empty() && item.dst.is_some()) {
949 let dst = item
950 .dst
951 .as_ref()
952 .ok_or_else(|| PushPlanError::InvalidRefspec {
953 spec: String::new(),
954 reason: "delete refspec missing destination".into(),
955 })?;
956 let resolved_dst = resolve_dst_name(repo, dst, None, ctx)?;
957 return Ok(vec![push_spec(None, None, resolved_dst, item.force, true)]);
958 }
959
960 let src_oid = if item.exact_sha1 {
961 ObjectId::from_hex(src_name).map_err(|_| PushPlanError::UnknownRevision {
962 spec: src_name.to_owned(),
963 })?
964 } else if src_name.is_empty() {
965 resolve_src_ref(repo, "HEAD")?
966 } else {
967 match resolve_src_ref(repo, src_name) {
968 Ok(oid) => oid,
969 Err(_) => {
970 let local = list_local_refs(repo.git_dir(), "refs/heads/")?;
971 let local_tags = list_local_refs(repo.git_dir(), "refs/tags/")?;
972 let mut all = local;
973 all.extend(local_tags);
974 match count_refname_match(src_name, &all) {
975 0 => {
976 return Err(PushPlanError::SrcRefspecNoMatch {
977 spec: src_name.to_owned(),
978 });
979 }
980 1 => {
981 let Some((name, oid)) = refname_match(src_name, &all) else {
982 return Err(PushPlanError::SrcRefspecNoMatch {
983 spec: src_name.to_owned(),
984 });
985 };
986 let dst = resolve_dst_for_src(repo, item, &name, ctx)?;
987 return Ok(vec![push_spec(
988 Some(oid),
989 Some(name.clone()),
990 dst,
991 item.force,
992 false,
993 )]);
994 }
995 _ => {
996 return Err(PushPlanError::SrcRefspecAmbiguous {
997 spec: src_name.to_owned(),
998 });
999 }
1000 }
1001 }
1002 }
1003 };
1004
1005 let src_full = if item.exact_sha1 {
1006 None
1007 } else {
1008 Some(full_src_ref_name(repo, src_name)?)
1009 };
1010 let src_label = src_full.clone();
1011 let dst = resolve_dst_for_src(repo, item, src_label.as_deref().unwrap_or(src_name), ctx)?;
1012 Ok(vec![push_spec(
1013 Some(src_oid),
1014 src_label,
1015 dst,
1016 item.force,
1017 false,
1018 )])
1019}
1020
1021fn resolve_dst_for_src(
1022 repo: &Repository,
1023 item: &RefspecItem,
1024 src_full: &str,
1025 ctx: &PushPlanContext,
1026) -> PlanResult<String> {
1027 if let Some(dst) = &item.dst {
1028 if dst.is_empty() {
1029 return Err(PushPlanError::InvalidRefspec {
1030 spec: String::new(),
1031 reason: "empty destination".into(),
1032 });
1033 }
1034 return resolve_dst_name(repo, dst, Some(src_full), ctx);
1035 }
1036 if src_full.starts_with("refs/heads/") {
1038 return Ok(src_full.to_owned());
1039 }
1040 if src_full.starts_with("refs/tags/") {
1041 return Ok(src_full.to_owned());
1042 }
1043 Ok(src_full.to_owned())
1044}
1045
1046fn resolve_dst_name(
1047 _repo: &Repository,
1048 dst: &str,
1049 src_full: Option<&str>,
1050 ctx: &PushPlanContext,
1051) -> PlanResult<String> {
1052 if dst.starts_with("refs/") {
1053 return Ok(dst.to_owned());
1054 }
1055 let remote_list = remote_ref_list(ctx);
1056 match count_refname_match(dst, &remote_list) {
1057 0 => {}
1058 1 => {
1059 let Some((name, _)) = refname_match(dst, &remote_list) else {
1060 return Err(PushPlanError::InvalidRefspec {
1061 spec: dst.to_owned(),
1062 reason: "remote ref match failed".into(),
1063 });
1064 };
1065 return Ok(name);
1066 }
1067 _ => {
1068 return Err(PushPlanError::DstRefspecAmbiguous {
1069 spec: dst.to_owned(),
1070 });
1071 }
1072 }
1073 if let Some(src) = src_full {
1074 if let Some(guessed) = guess_qualified_dst(src, dst) {
1075 return Ok(guessed);
1076 }
1077 }
1078 Ok(format!("refs/heads/{dst}"))
1079}
1080
1081fn remote_ref_list(ctx: &PushPlanContext) -> Vec<(String, ObjectId)> {
1082 ctx.remote_ref_names
1083 .iter()
1084 .filter_map(|n| {
1085 ObjectId::from_hex("0".repeat(40).as_str())
1087 .ok()
1088 .map(|z| (n.clone(), z))
1089 })
1090 .collect()
1091}
1092
1093fn guess_qualified_dst(src_full: &str, short_dst: &str) -> Option<String> {
1094 if src_full.starts_with("refs/heads/") {
1095 Some(format!("refs/heads/{short_dst}"))
1096 } else if src_full.starts_with("refs/tags/") {
1097 Some(format!("refs/tags/{short_dst}"))
1098 } else {
1099 None
1100 }
1101}
1102
1103fn apply_force_mode(
1105 repo: &Repository,
1106 config: &ConfigSet,
1107 remote: &str,
1108 specs: &mut [PushRefSpec],
1109 mode: &ForceMode,
1110) -> PlanResult<()> {
1111 match mode {
1112 ForceMode::None => {}
1113 ForceMode::Force => {
1114 for spec in specs.iter_mut() {
1115 spec.force = true;
1116 spec.expected_old = None;
1117 spec.expect_absent = false;
1118 }
1119 }
1120 ForceMode::Lease(LeaseExpectation::Tracking) => {
1121 for spec in specs.iter_mut() {
1122 apply_tracking_lease(repo, config, remote, spec);
1123 }
1124 }
1125 ForceMode::Lease(LeaseExpectation::Value(expected)) => {
1126 if specs.len() != 1 {
1127 return Err(PushPlanError::LeaseValueRequiresSingleRef);
1128 }
1129 for spec in specs.iter_mut() {
1130 spec.force = true;
1131 spec.expected_old = Some(*expected);
1132 spec.expect_absent = false;
1133 }
1134 }
1135 }
1136 Ok(())
1137}
1138
1139fn apply_tracking_lease(
1140 repo: &Repository,
1141 config: &ConfigSet,
1142 remote: &str,
1143 spec: &mut PushRefSpec,
1144) {
1145 spec.force = true;
1146 spec.expected_old = None;
1147 spec.expect_absent = false;
1148 if let Some(tracking) = remote_tracking_ref_for_remote_ref(config, remote, &spec.dst) {
1149 match refs::resolve_ref(repo.git_dir(), &tracking) {
1150 Ok(oid) => spec.expected_old = Some(oid),
1151 Err(_) => spec.expect_absent = true,
1152 }
1153 } else {
1154 spec.expect_absent = true;
1155 }
1156}
1157
1158fn push_spec(
1159 src: Option<ObjectId>,
1160 src_ref: Option<String>,
1161 dst: String,
1162 force: bool,
1163 delete: bool,
1164) -> PushRefSpec {
1165 PushRefSpec {
1166 src,
1167 src_ref,
1168 dst,
1169 force,
1170 delete,
1171 ..Default::default()
1172 }
1173}
1174
1175fn resolve_src_ref(repo: &Repository, spec: &str) -> PlanResult<ObjectId> {
1176 resolve_revision_for_verify(repo, spec).map_err(|_| PushPlanError::UnknownRevision {
1177 spec: spec.to_owned(),
1178 })
1179}
1180
1181fn full_src_ref_name(repo: &Repository, spec: &str) -> PlanResult<String> {
1182 if spec.eq_ignore_ascii_case("HEAD") {
1183 if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
1184 return Ok(format!("refs/heads/{short_name}"));
1185 }
1186 }
1187 if spec.starts_with("refs/") {
1188 return Ok(spec.to_owned());
1189 }
1190 let heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
1191 if let Some((name, _)) = refname_match(spec, &heads) {
1192 return Ok(name);
1193 }
1194 let tags = list_local_refs(repo.git_dir(), "refs/tags/")?;
1195 if let Some((name, _)) = refname_match(spec, &tags) {
1196 return Ok(name);
1197 }
1198 Ok(spec.to_owned())
1199}
1200
1201fn list_local_refs(git_dir: &Path, prefix: &str) -> PlanResult<Vec<(String, ObjectId)>> {
1202 refs::list_refs(git_dir, prefix).map_err(|e| PushPlanError::Message(e.to_string()))
1203}
1204
1205fn count_refname_match(pattern: &str, refs: &[(String, ObjectId)]) -> usize {
1207 let mut strong = 0usize;
1208 let mut weak = 0usize;
1209 let patlen = pattern.len();
1210 for (name, _) in refs {
1211 if !refname_match_one(pattern, name) {
1212 continue;
1213 }
1214 let namelen = name.len();
1215 if namelen != patlen
1216 && patlen != namelen.saturating_sub(5)
1217 && !name.starts_with("refs/heads/")
1218 && !name.starts_with("refs/tags/")
1219 {
1220 weak += 1;
1221 } else {
1222 strong += 1;
1223 }
1224 }
1225 if strong == 0 {
1226 weak
1227 } else {
1228 strong
1229 }
1230}
1231
1232fn refname_match(pattern: &str, refs: &[(String, ObjectId)]) -> Option<(String, ObjectId)> {
1233 let mut strong: Option<(String, ObjectId)> = None;
1234 let mut weak: Option<(String, ObjectId)> = None;
1235 let patlen = pattern.len();
1236 for (name, oid) in refs {
1237 if !refname_match_one(pattern, name) {
1238 continue;
1239 }
1240 let namelen = name.len();
1241 if namelen != patlen
1242 && patlen != namelen.saturating_sub(5)
1243 && !name.starts_with("refs/heads/")
1244 && !name.starts_with("refs/tags/")
1245 {
1246 weak = Some((name.clone(), *oid));
1247 } else {
1248 strong = Some((name.clone(), *oid));
1249 }
1250 }
1251 strong.or(weak)
1252}
1253
1254fn refname_match_one(pattern: &str, refname: &str) -> bool {
1255 if pattern == refname {
1256 return true;
1257 }
1258 if refname.ends_with(pattern) {
1259 if refname == format!("refs/heads/{pattern}") || refname == format!("refs/tags/{pattern}") {
1261 return true;
1262 }
1263 if pattern.len() + 5 == refname.len() && refname.starts_with("refs/heads/") {
1264 return refname.strip_prefix("refs/heads/") == Some(pattern);
1265 }
1266 if pattern.len() + 5 == refname.len() && refname.starts_with("refs/tags/") {
1267 return refname.strip_prefix("refs/tags/") == Some(pattern);
1268 }
1269 }
1270 false
1271}
1272
1273fn refspec_map_src(item: &RefspecItem, src_ref: &str) -> Option<String> {
1274 let src_pat = item.src.as_deref()?;
1275 let dst_pat = item.dst.as_deref()?;
1276 match_refname_with_pattern(src_pat, src_ref, dst_pat)
1277}
1278
1279fn push_spec_excluded_by_negative(item: &RefspecItem, spec: &PushRefSpec) -> bool {
1281 if !item.negative {
1282 return false;
1283 }
1284 let Some(pat) = item.src.as_deref() else {
1285 return false;
1286 };
1287 let local = spec.src_ref.as_deref().or_else(|| {
1288 spec.dst
1289 .starts_with("refs/heads/")
1290 .then_some(spec.dst.as_str())
1291 });
1292 let Some(local) = local else {
1293 return false;
1294 };
1295 if item.pattern {
1296 return match_refname_with_pattern(pat, local, pat).is_some();
1297 }
1298 pat == local || refname_match_one(pat, local)
1299}
1300
1301fn match_refname_with_pattern(pattern: &str, refname: &str, replacement: &str) -> Option<String> {
1302 match (pattern.find('*'), replacement.find('*')) {
1303 (Some(pstar), Some(vstar)) => {
1304 let kprefix = &pattern[..pstar];
1305 let ksuffix = &pattern[pstar + 1..];
1306 if !refname.starts_with(kprefix) || !refname.ends_with(ksuffix) {
1307 return None;
1308 }
1309 if refname.len() < kprefix.len() + ksuffix.len() {
1310 return None;
1311 }
1312 let middle = &refname[kprefix.len()..refname.len() - ksuffix.len()];
1313 Some(format!(
1314 "{}{}{}",
1315 &replacement[..vstar],
1316 middle,
1317 &replacement[vstar + 1..]
1318 ))
1319 }
1320 (None, None) if pattern == refname => Some(replacement.to_owned()),
1321 _ => None,
1322 }
1323}
1324
1325fn delete_branch_label(specs: &[PushRefSpec]) -> Option<String> {
1326 if specs.len() != 1 {
1327 return None;
1328 }
1329 specs[0].dst.strip_prefix("refs/heads/").map(str::to_owned)
1330}
1331
1332fn branch_label_from_specs(_repo: &Repository, specs: &[PushRefSpec]) -> Option<String> {
1333 if specs.len() != 1 || specs[0].delete {
1334 return None;
1335 }
1336 let dst = &specs[0].dst;
1337 dst.strip_prefix("refs/heads/")
1338 .or_else(|| dst.strip_prefix("refs/tags/"))
1339 .map(str::to_owned)
1340}
1341
1342#[cfg(test)]
1343mod tests {
1344 use super::*;
1345 use crate::config::environment::Environment;
1346 use crate::repo::{InitOptions, Repository};
1347
1348 fn init_repo() -> (tempfile::TempDir, Repository) {
1349 let tmp = tempfile::tempdir().expect("tempdir");
1350 let repo = Repository::init(tmp.path(), &InitOptions::default()).expect("init");
1351 (tmp, repo)
1352 }
1353
1354 #[test]
1355 fn push_remote_precedence_pushremote_over_remote() {
1356 let (_tmp, repo) = init_repo();
1357 let git_dir = repo.git_dir();
1358 std::fs::write(
1359 git_dir.join("config"),
1360 r#"[remote "origin"]
1361 url = /tmp/o.git
1362[remote "upstream"]
1363 url = /tmp/u.git
1364[branch "main"]
1365 remote = origin
1366 pushRemote = upstream
1367"#,
1368 )
1369 .expect("write config");
1370 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
1371 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1372 let remote = configured_remote_for_push(&repo, &config, None).expect("remote");
1373 assert_eq!(remote, "upstream");
1374 }
1375
1376 #[test]
1377 fn map_through_skips_malformed_remote_push_refspec() {
1378 let (_tmp, repo) = init_repo();
1379 let git_dir = repo.git_dir();
1380 std::fs::write(
1381 git_dir.join("config"),
1382 r#"[remote "origin"]
1383 url = /tmp/o.git
1384 push = not a valid refspec at all
1385 push = refs/heads/*:refs/remotes/origin/*
1386"#,
1387 )
1388 .expect("write config");
1389 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
1390 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1391 let mapped = map_through_remote_push_refspec(&config, "origin", "refs/heads/main");
1392 assert_eq!(
1393 mapped.as_deref(),
1394 Some("refs/heads/main:refs/remotes/origin/main")
1395 );
1396 }
1397
1398 #[test]
1399 fn force_with_lease_tracking_sets_expected_old() {
1400 let (_tmp, repo) = init_repo();
1401 let git_dir = repo.git_dir();
1402 std::fs::write(
1403 git_dir.join("config"),
1404 r#"[remote "origin"]
1405 url = /tmp/o.git
1406 fetch = +refs/heads/*:refs/remotes/origin/*
1407[branch "main"]
1408 remote = origin
1409 merge = refs/heads/main
1410"#,
1411 )
1412 .expect("config");
1413 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
1414 let oid = ObjectId::from_hex("a".repeat(40).as_str()).expect("oid");
1415 std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads dir");
1416 std::fs::write(
1417 git_dir.join("refs/heads/main"),
1418 format!("{}\n", oid.to_hex()),
1419 )
1420 .expect("main");
1421 std::fs::create_dir_all(git_dir.join("refs/remotes/origin")).expect("rt dir");
1422 std::fs::write(
1423 git_dir.join("refs/remotes/origin/main"),
1424 format!("{}\n", oid.to_hex()),
1425 )
1426 .expect("tracking");
1427 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1428 let ctx = PushPlanContext::default();
1429 let resolved = resolve_push(
1430 &repo,
1431 &config,
1432 &PushRequest {
1433 force: ForceMode::Lease(LeaseExpectation::Tracking),
1434 ..Default::default()
1435 },
1436 &ctx,
1437 )
1438 .expect("resolve");
1439 assert_eq!(resolved.specs.len(), 1);
1440 assert_eq!(resolved.specs[0].expected_old, Some(oid));
1441 assert!(resolved.specs[0].force);
1442 }
1443
1444 #[test]
1445 fn negative_push_refspec_filters_planned_spec() {
1446 let item = parse_push_refspec("^refs/heads/private").expect("parse");
1447 let kept = push_spec(
1448 Some(ObjectId::from_hex("0".repeat(40).as_str()).expect("oid")),
1449 Some("refs/heads/main".into()),
1450 "refs/heads/main".into(),
1451 false,
1452 false,
1453 );
1454 let dropped = push_spec(
1455 Some(ObjectId::from_hex("1".repeat(40).as_str()).expect("oid")),
1456 Some("refs/heads/private".into()),
1457 "refs/heads/private".into(),
1458 false,
1459 false,
1460 );
1461 assert!(!push_spec_excluded_by_negative(&item, &kept));
1462 assert!(push_spec_excluded_by_negative(&item, &dropped));
1463 }
1464
1465 fn config_from(text: &str) -> ConfigSet {
1466 let file = crate::config::ConfigFile::parse(
1467 Path::new("config"),
1468 text,
1469 crate::config::ConfigScope::Local,
1470 )
1471 .expect("parse config");
1472 let mut config = ConfigSet::new();
1473 config.merge(&file);
1474 config
1475 }
1476
1477 fn strings(items: &[&str]) -> Vec<String> {
1478 items.iter().map(|s| (*s).to_owned()).collect()
1479 }
1480
1481 #[test]
1482 fn push_invocation_remote_flag_keeps_every_positional_as_refspec() {
1483 let config = ConfigSet::new();
1484 let invocation =
1485 resolve_push_invocation(&config, Some("upstream"), &strings(&["origin", "main"]))
1486 .expect("invocation");
1487 assert_eq!(
1488 invocation,
1489 PushInvocation {
1490 remote: Some("upstream".into()),
1491 refspecs: strings(&["origin", "main"]),
1492 }
1493 );
1494 }
1495
1496 #[test]
1497 fn push_invocation_without_positionals_uses_default_remote() {
1498 let invocation = resolve_push_invocation(&ConfigSet::new(), None, &[]).expect("invocation");
1499 assert_eq!(invocation, PushInvocation::default());
1500 }
1501
1502 #[test]
1503 fn push_invocation_first_positional_is_configured_remote() {
1504 let config = config_from("[remote \"upstream\"]\n\turl = /tmp/u.git\n");
1505 for positionals in [strings(&["upstream"]), strings(&["upstream", "a", "b:c"])] {
1506 let invocation =
1507 resolve_push_invocation(&config, None, &positionals).expect("invocation");
1508 assert_eq!(invocation.remote.as_deref(), Some("upstream"));
1509 assert_eq!(invocation.refspecs, positionals[1..]);
1510 }
1511 }
1512
1513 #[test]
1514 fn push_invocation_first_positional_is_literal_url_or_path() {
1515 let config = ConfigSet::new();
1516 for remote in ["https://example.com/r", "../sibling.git", "/srv/repo"] {
1517 let invocation = resolve_push_invocation(&config, None, &strings(&[remote, "main"]))
1518 .expect("invocation");
1519 assert_eq!(invocation.remote.as_deref(), Some(remote));
1520 assert_eq!(invocation.refspecs, strings(&["main"]));
1521 }
1522 }
1523
1524 #[test]
1525 fn push_invocation_single_unknown_positional_is_a_refspec() {
1526 let config = config_from("[remote \"blank\"]\n\turl = \n");
1527 for token in ["main", "HEAD:refs/heads/x", "blank"] {
1528 let invocation =
1529 resolve_push_invocation(&config, None, &strings(&[token])).expect("invocation");
1530 assert_eq!(invocation.remote, None, "{token}");
1531 assert_eq!(invocation.refspecs, strings(&[token]));
1532 }
1533 }
1534
1535 #[test]
1536 fn push_without_any_remote_is_no_remote() {
1537 let (_tmp, repo) = init_repo();
1538 assert_eq!(
1539 configured_remote_for_push(&repo, &ConfigSet::new(), None),
1540 Err(PushPlanError::NoRemote)
1541 );
1542 assert_eq!(PushPlanError::NoRemote.to_string(), "no remote to push to");
1543 assert_eq!(
1544 load_push_plan_context(&repo, &ConfigSet::new(), None, None).map(|_| ()),
1545 Err(PushPlanError::NoRemote)
1546 );
1547 let named = configured_remote_for_push(&repo, &ConfigSet::new(), Some("upstream"));
1548 assert_eq!(named.as_deref(), Ok("upstream"));
1549 let url_less_origin = config_from("[remote \"origin\"]\n\tpushurl = /tmp/o.git\n");
1550 assert_eq!(
1551 configured_remote_for_push(&repo, &url_less_origin, None).as_deref(),
1552 Ok("origin")
1553 );
1554 }
1555
1556 #[test]
1557 fn push_invocation_unknown_remote_before_refspecs_is_rejected() {
1558 let err = resolve_push_invocation(&ConfigSet::new(), None, &strings(&["nosuch", "main"]))
1559 .expect_err("unknown remote");
1560 assert_eq!(err, PushPlanError::UnknownRemote("nosuch".into()));
1561 }
1562
1563 #[test]
1564 fn open_push_destination_reads_config_or_treats_literal_as_url() {
1565 let config = config_from(
1566 "[remote \"origin\"]\n\turl = /tmp/o.git\n[remote \"nourl\"]\n\tfetch = +refs/heads/*:refs/remotes/nourl/*\n",
1567 );
1568 let origin = open_push_destination(&config, "origin").expect("origin");
1569 assert_eq!(origin.name.as_deref(), Some("origin"));
1570 let literal = open_push_destination(&config, "/srv/other.git").expect("literal");
1571 assert_eq!(literal.name, None);
1572 assert!(matches!(
1573 open_push_destination(&config, "nourl"),
1574 Err(RemoteError::NoUrl { remote: Some(name) }) if name == "nourl"
1575 ));
1576 }
1577
1578 #[test]
1579 fn plan_context_from_advertisement_skips_peeled_entries() {
1580 let oid = |c: &str| ObjectId::from_hex(&c.repeat(40)).expect("oid");
1581 let remote_ref = |name: &str, hex: &str| RemoteRef {
1582 name: name.to_owned(),
1583 oid: oid(hex),
1584 symref_target: None,
1585 };
1586 let ctx = PushPlanContext::from_advertised_refs([
1587 remote_ref("HEAD", "1"),
1588 remote_ref("refs/heads/main", "1"),
1589 remote_ref("refs/heads/topic", "2"),
1590 remote_ref("refs/tags/v1", "3"),
1591 remote_ref("refs/tags/v1^{}", "4"),
1592 ]);
1593 let mut names: Vec<_> = ctx.remote_ref_names.iter().map(String::as_str).collect();
1594 names.sort_unstable();
1595 assert_eq!(
1596 names,
1597 [
1598 "HEAD",
1599 "refs/heads/main",
1600 "refs/heads/topic",
1601 "refs/tags/v1"
1602 ]
1603 );
1604 assert_eq!(ctx.remote_branch_tips, HashSet::from([oid("1"), oid("2")]));
1605 assert_eq!(
1606 ctx.remote_tips,
1607 Some(vec![oid("1"), oid("1"), oid("2"), oid("3")])
1608 );
1609 }
1610
1611 #[test]
1612 fn plan_context_from_empty_advertisement_knows_remote_has_no_tips() {
1613 let ctx = PushPlanContext::from_advertised_refs([]);
1614 assert_eq!(ctx.remote_tips, Some(Vec::new()));
1615 }
1616
1617 #[test]
1618 fn plan_context_is_empty_for_unconfigured_remote() {
1619 let (_tmp, repo) = init_repo();
1620 let ctx = load_push_plan_context(&repo, &ConfigSet::new(), Some("nosuch"), None)
1621 .expect("context");
1622 assert!(ctx.remote_ref_names.is_empty());
1623 assert!(ctx.remote_branch_tips.is_empty());
1624 assert_eq!(ctx.remote_tips, None);
1625 }
1626
1627 #[test]
1628 fn plan_context_for_missing_path_assumes_no_remote_objects() {
1629 let (tmp, repo) = init_repo();
1630 let missing = tmp.path().join("missing.git");
1631 let missing = missing.to_str().expect("utf-8 path");
1632 let ctx =
1633 load_push_plan_context(&repo, &ConfigSet::new(), Some(missing), None).expect("context");
1634 assert!(ctx.remote_ref_names.is_empty());
1635 assert!(ctx.remote_tips.as_ref().is_none_or(Vec::is_empty));
1636 }
1637
1638 #[test]
1639 fn tag_push_branch_label_is_tag_name_not_current_branch() {
1640 let (_tmp, repo) = init_repo();
1641 let git_dir = repo.git_dir();
1642 std::fs::write(
1643 git_dir.join("config"),
1644 r#"[remote "origin"]
1645 url = /tmp/o.git
1646"#,
1647 )
1648 .expect("config");
1649 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
1650 let oid = ObjectId::from_hex("a".repeat(40).as_str()).expect("oid");
1651 std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads");
1652 std::fs::write(
1653 git_dir.join("refs/heads/main"),
1654 format!("{}\n", oid.to_hex()),
1655 )
1656 .expect("main");
1657 std::fs::create_dir_all(git_dir.join("refs/tags")).expect("tags");
1658 std::fs::write(git_dir.join("refs/tags/v1"), format!("{}\n", oid.to_hex())).expect("tag");
1659 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1660 let ctx = PushPlanContext::default();
1661 let resolved = resolve_push(
1662 &repo,
1663 &config,
1664 &PushRequest {
1665 remote: Some("origin".to_owned()),
1666 refspecs: vec!["v1".to_owned()],
1667 ..Default::default()
1668 },
1669 &ctx,
1670 )
1671 .expect("resolve tag push");
1672 assert_eq!(resolved.branch_label.as_deref(), Some("v1"));
1673 assert_eq!(resolved.kind, PushPlanKind::Refspecs);
1674 assert_eq!(resolved.specs[0].dst, "refs/tags/v1");
1675 }
1676}