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, ProbeHttpClientFactory, 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_refs: bool,
67 pub delete: Vec<String>,
70 pub force: ForceMode,
72 pub follow_tags: bool,
74 pub set_upstream: bool,
76}
77
78#[derive(Debug, Clone)]
80pub struct ResolvedPush {
81 pub remote: String,
83 pub specs: Vec<PushRefSpec>,
85 pub branch_label: Option<String>,
87 pub kind: PushPlanKind,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum PushPlanKind {
94 Branch,
96 Tags,
98 Delete,
100 Refspecs,
102}
103
104#[derive(Debug, Clone, Default)]
106pub struct PushPlanContext {
107 pub remote_ref_names: HashSet<String>,
109 pub remote_branch_tips: HashSet<ObjectId>,
112 pub remote_tips: Option<Vec<ObjectId>>,
119}
120
121#[derive(Debug, Error, PartialEq, Eq)]
123pub enum PushPlanError {
124 #[error("HEAD is detached; specify a refspec (for example HEAD:refs/heads/<name>)")]
126 DetachedHeadNoRefspec,
127 #[error("no commits yet to push")]
129 NoCommits,
130 #[error("no tags to push")]
132 NoTags,
133 #[error("push.default is nothing; no push destination")]
135 PushDefaultNothing,
136 #[error("no remote to push to")]
139 NoRemote,
140 #[error("unknown remote '{0}'")]
142 UnknownRemote(String),
143 #[error("invalid refspec '{spec}': {reason}")]
145 InvalidRefspec {
146 spec: String,
148 reason: String,
150 },
151 #[error("src refspec {spec} does not match any ref")]
153 SrcRefspecNoMatch {
154 spec: String,
156 },
157 #[error("src refspec {spec} matches more than one ref")]
159 SrcRefspecAmbiguous {
160 spec: String,
162 },
163 #[error("dst refspec {spec} matches more than one remote ref")]
165 DstRefspecAmbiguous {
166 spec: String,
168 },
169 #[error("could not resolve revision '{spec}'")]
171 UnknownRevision {
172 spec: String,
174 },
175 #[error("--force-with-lease=<rev> requires pushing exactly one ref")]
177 LeaseValueRequiresSingleRef,
178 #[error("cannot delete '{spec}': --delete takes ref names, not src:dst refspecs")]
180 DeleteNotPlainName {
181 spec: String,
183 },
184 #[error("--delete needs at least one ref to delete")]
186 DeleteWithoutRefs,
187 #[error("{0}")]
189 Message(String),
190}
191
192type PlanResult<T> = std::result::Result<T, PushPlanError>;
193
194pub fn parse_force_mode(
196 repo: &Repository,
197 force: bool,
198 force_with_lease: Option<Option<String>>,
199) -> PlanResult<ForceMode> {
200 if force {
201 return Ok(ForceMode::Force);
202 }
203 let Some(flag) = force_with_lease else {
204 return Ok(ForceMode::None);
205 };
206 match flag {
207 None => Ok(ForceMode::Lease(LeaseExpectation::Tracking)),
208 Some(rev) => {
209 let oid = crate::rev_parse::resolve_revision_for_verify(repo, &rev)
210 .map_err(|_| PushPlanError::UnknownRevision { spec: rev.clone() })?;
211 Ok(ForceMode::Lease(LeaseExpectation::Value(oid)))
212 }
213 }
214}
215
216pub fn resolve_push(
230 repo: &Repository,
231 config: &ConfigSet,
232 request: &PushRequest,
233 ctx: &PushPlanContext,
234) -> PlanResult<ResolvedPush> {
235 if request.delete_refs || !request.delete.is_empty() {
236 return resolve_delete_push(repo, config, request, ctx);
237 }
238 if request.tags {
239 return resolve_tags_push(repo, config, request);
240 }
241
242 let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
243 validate_remote(config, &remote)?;
244
245 let follow_tags = request.follow_tags || push_follow_tags_config(config);
246
247 let mut resolved = if request.refspecs.is_empty() {
248 resolve_default_push(repo, config, &remote, ctx)?
249 } else {
250 let mut specs = Vec::new();
251 for spec in &request.refspecs {
252 specs.extend(resolve_cli_refspec(repo, config, &remote, spec, ctx)?);
253 }
254 let branch_label = branch_label_from_specs(repo, &specs);
255 ResolvedPush {
256 remote,
257 specs,
258 branch_label,
259 kind: PushPlanKind::Refspecs,
260 }
261 };
262
263 if follow_tags {
264 let extra = follow_tag_push_specs(repo, &resolved.specs, ctx)?;
265 resolved.specs.extend(extra);
266 }
267
268 apply_force_mode(
269 repo,
270 config,
271 &resolved.remote,
272 &mut resolved.specs,
273 &request.force,
274 )?;
275 Ok(resolved)
276}
277
278#[must_use]
280pub fn push_follow_tags_config(config: &ConfigSet) -> bool {
281 config
282 .get("push.followTags")
283 .or_else(|| config.get("push.followtags"))
284 .and_then(|v| parse_git_config_maybe_bool(&v))
285 .unwrap_or(false)
286}
287
288pub fn follow_tag_push_specs(
294 repo: &Repository,
295 specs: &[PushRefSpec],
296 ctx: &PushPlanContext,
297) -> PlanResult<Vec<PushRefSpec>> {
298 let tips = push_follow_tip_commits(repo, specs)?;
299 if tips.is_empty() {
300 return Ok(Vec::new());
301 }
302
303 let existing: HashSet<&str> = specs.iter().map(|s| s.dst.as_str()).collect();
304 let odb = repo.odb();
305 let mut out = Vec::new();
306 for (tag_name, tag_oid) in list_local_refs(repo.git_dir(), "refs/tags/")? {
307 if ctx.remote_ref_names.contains(&tag_name) || existing.contains(tag_name.as_str()) {
308 continue;
309 }
310 let Ok(obj) = odb.read(&tag_oid) else {
311 continue;
312 };
313 if obj.kind != ObjectKind::Tag {
314 continue;
315 }
316 let Ok((peeled, _)) = odb.peel_tags(tag_oid) else {
317 continue;
318 };
319 if !tag_reachable_from_push_tips(repo, peeled, &tips)? {
320 continue;
321 }
322 out.push(push_spec(
323 Some(tag_oid),
324 Some(tag_name.clone()),
325 tag_name,
326 false,
327 false,
328 ));
329 }
330 Ok(out)
331}
332
333pub fn apply_set_upstream_after_push(
338 repo: &Repository,
339 remote: &str,
340 set_upstream: bool,
341 dry_run: bool,
342 specs: &[PushRefSpec],
343 results: &[PushRefResult],
344) -> Result<Vec<String>, crate::error::Error> {
345 if !set_upstream || dry_run {
346 return Ok(Vec::new());
347 }
348 apply_push_set_upstream(repo, remote, specs, results)
349}
350
351fn push_follow_tip_commits(repo: &Repository, specs: &[PushRefSpec]) -> PlanResult<Vec<ObjectId>> {
352 let mut tips = Vec::new();
353 for spec in specs {
354 if spec.delete || is_tag_push_spec(repo, spec) {
355 continue;
356 }
357 if let Some(commit) = peel_push_src_to_commit(repo, spec) {
358 tips.push(commit);
359 }
360 }
361 Ok(tips)
362}
363
364fn is_tag_push_spec(repo: &Repository, spec: &PushRefSpec) -> bool {
365 if spec.dst.starts_with("refs/tags/") {
366 return true;
367 }
368 let Some(src) = spec.src else {
369 return false;
370 };
371 let Ok(obj) = repo.odb().read(&src) else {
372 return false;
373 };
374 obj.kind == ObjectKind::Tag
375}
376
377fn peel_push_src_to_commit(repo: &Repository, spec: &PushRefSpec) -> Option<ObjectId> {
378 let src = spec.src?;
379 let odb = repo.odb();
380 let Ok(obj) = odb.read(&src) else {
381 return None;
382 };
383 match obj.kind {
384 ObjectKind::Commit => Some(src),
385 ObjectKind::Tag => odb.peel_tags(src).ok().map(|(commit, _)| commit),
386 _ => None,
387 }
388}
389
390fn tag_reachable_from_push_tips(
391 repo: &Repository,
392 peeled: ObjectId,
393 tips: &[ObjectId],
394) -> PlanResult<bool> {
395 for &tip in tips {
396 if peeled == tip {
397 return Ok(true);
398 }
399 if is_ancestor(repo, peeled, tip).map_err(|e| PushPlanError::Message(e.to_string()))? {
400 return Ok(true);
401 }
402 }
403 Ok(false)
404}
405
406fn resolve_delete_push(
407 repo: &Repository,
408 config: &ConfigSet,
409 request: &PushRequest,
410 ctx: &PushPlanContext,
411) -> PlanResult<ResolvedPush> {
412 let names: Vec<&String> = request.delete.iter().chain(&request.refspecs).collect();
413 if let Some(spec) = names.iter().find(|name| name.contains(':')) {
414 return Err(PushPlanError::DeleteNotPlainName {
415 spec: (*spec).clone(),
416 });
417 }
418 if names.is_empty() {
419 return Err(PushPlanError::DeleteWithoutRefs);
420 }
421 let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
422 validate_remote(config, &remote)?;
423 let specs = names
424 .into_iter()
425 .map(|name| {
426 let dst = resolve_push_delete_ref(repo.git_dir(), &ctx.remote_ref_names, name);
427 push_spec(None, None, dst, false, true)
428 })
429 .collect::<Vec<_>>();
430 let branch_label = delete_branch_label(&specs);
431 let mut resolved = ResolvedPush {
432 remote,
433 specs,
434 branch_label,
435 kind: PushPlanKind::Delete,
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_tags_push(
448 repo: &Repository,
449 config: &ConfigSet,
450 request: &PushRequest,
451) -> PlanResult<ResolvedPush> {
452 let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
453 validate_remote(config, &remote)?;
454 let entries = refs::list_refs(repo.git_dir(), "refs/tags/")
455 .map_err(|e| PushPlanError::Message(e.to_string()))?;
456 if entries.is_empty() {
457 return Err(PushPlanError::NoTags);
458 }
459 let specs = entries
460 .into_iter()
461 .map(|(refname, oid)| push_spec(Some(oid), Some(refname.clone()), refname, false, false))
462 .collect();
463 let mut resolved = ResolvedPush {
464 remote,
465 specs,
466 branch_label: None,
467 kind: PushPlanKind::Tags,
468 };
469 apply_force_mode(
470 repo,
471 config,
472 &resolved.remote,
473 &mut resolved.specs,
474 &request.force,
475 )?;
476 Ok(resolved)
477}
478
479fn resolve_default_push(
480 repo: &Repository,
481 config: &ConfigSet,
482 remote: &str,
483 ctx: &PushPlanContext,
484) -> PlanResult<ResolvedPush> {
485 let push_specs = remote_push_refspec_strings(config, remote);
486 if !push_specs.is_empty() {
487 let mut positive_specs = Vec::new();
488 let mut negative_items = Vec::new();
489 for spec in push_specs {
490 let item = parse_push_refspec(&spec).map_err(|e| PushPlanError::InvalidRefspec {
491 spec: spec.clone(),
492 reason: e.to_string(),
493 })?;
494 if item.negative {
495 negative_items.push(item);
496 } else {
497 positive_specs.push(spec);
498 }
499 }
500 let mut specs = Vec::new();
501 for spec in positive_specs {
502 specs.extend(expand_configured_push_refspec(repo, spec, ctx)?);
503 }
504 for item in negative_items {
505 specs.retain(|s| !push_spec_excluded_by_negative(&item, s));
506 }
507 let branch_label = branch_label_from_specs(repo, &specs);
508 let kind = if specs.len() == 1 {
509 PushPlanKind::Branch
510 } else {
511 PushPlanKind::Refspecs
512 };
513 return Ok(ResolvedPush {
514 remote: remote.to_owned(),
515 specs,
516 branch_label,
517 kind,
518 });
519 }
520
521 let push_default = config
522 .get("push.default")
523 .unwrap_or_else(|| "simple".to_owned());
524 if push_default == "matching" {
525 return resolve_matching_default_push(repo, remote, ctx);
526 }
527
528 let (short_name, oid) = current_branch_oid(repo)?;
529 let src_ref = format!("refs/heads/{short_name}");
530 let dst = default_push_destination(repo, config, remote, &short_name, &src_ref)?;
531 let spec = push_spec(Some(oid), Some(src_ref.clone()), dst, false, false);
532 Ok(ResolvedPush {
533 remote: remote.to_owned(),
534 specs: vec![spec],
535 branch_label: Some(short_name),
536 kind: PushPlanKind::Branch,
537 })
538}
539
540fn resolve_matching_default_push(
542 repo: &Repository,
543 remote: &str,
544 ctx: &PushPlanContext,
545) -> PlanResult<ResolvedPush> {
546 let heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
547 let mut specs = Vec::new();
548 for (name, oid) in heads {
549 if ctx.remote_ref_names.contains(&name) {
550 specs.push(push_spec(
551 Some(oid),
552 Some(name.clone()),
553 name.clone(),
554 false,
555 false,
556 ));
557 }
558 }
559 let branch_label = branch_label_from_specs(repo, &specs);
560 let kind = if specs.len() == 1 {
561 PushPlanKind::Branch
562 } else {
563 PushPlanKind::Refspecs
564 };
565 Ok(ResolvedPush {
566 remote: remote.to_owned(),
567 specs,
568 branch_label,
569 kind,
570 })
571}
572
573fn current_branch_oid(repo: &Repository) -> PlanResult<(String, ObjectId)> {
574 match resolve_head(repo.git_dir()).map_err(|e| PushPlanError::Message(e.to_string()))? {
575 HeadState::Branch {
576 short_name,
577 oid: Some(oid),
578 ..
579 } => Ok((short_name, oid)),
580 HeadState::Branch { .. } => Err(PushPlanError::NoCommits),
581 HeadState::Detached { .. } => Err(PushPlanError::DetachedHeadNoRefspec),
582 HeadState::Invalid => Err(PushPlanError::Message("HEAD is in an unknown state".into())),
583 }
584}
585
586pub fn configured_remote_for_push(
594 repo: &Repository,
595 config: &ConfigSet,
596 remote_override: Option<&str>,
597) -> PlanResult<String> {
598 if let Some(remote) = remote_override.filter(|r| !r.trim().is_empty()) {
599 return Ok(remote.to_owned());
600 }
601 if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
602 if let Some(remote) = config
603 .get(&format!("branch.{short_name}.pushremote"))
604 .or_else(|| config.get(&format!("branch.{short_name}.pushRemote")))
605 .filter(|r| !r.trim().is_empty())
606 {
607 return Ok(remote);
608 }
609 }
610 if let Some(remote) = config
611 .get("remote.pushdefault")
612 .or_else(|| config.get("remote.pushDefault"))
613 .filter(|r| !r.trim().is_empty())
614 {
615 return Ok(remote);
616 }
617 if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
618 if let Some(remote) = config
619 .get(&format!("branch.{short_name}.remote"))
620 .filter(|r| !r.trim().is_empty() && r != ".")
621 {
622 return Ok(remote);
623 }
624 }
625 if !has_remote_section(config, DEFAULT_REMOTE) {
626 return Err(PushPlanError::NoRemote);
627 }
628 Ok(DEFAULT_REMOTE.to_owned())
629}
630
631#[derive(Debug, Clone, Default, PartialEq, Eq)]
633pub struct PushInvocation {
634 pub remote: Option<String>,
636 pub refspecs: Vec<String>,
638}
639
640pub fn resolve_push_invocation(
652 config: &ConfigSet,
653 remote_flag: Option<&str>,
654 positionals: &[String],
655) -> PlanResult<PushInvocation> {
656 if let Some(remote) = remote_flag {
657 return Ok(PushInvocation {
658 remote: Some(remote.to_owned()),
659 refspecs: positionals.to_vec(),
660 });
661 }
662 let Some((first, rest)) = positionals.split_first() else {
663 return Ok(PushInvocation::default());
664 };
665 if has_remote_url(config, first) || looks_like_literal_remote(first) {
666 return Ok(PushInvocation {
667 remote: Some(first.clone()),
668 refspecs: rest.to_vec(),
669 });
670 }
671 if !rest.is_empty() {
672 return Err(PushPlanError::UnknownRemote(first.clone()));
673 }
674 Ok(PushInvocation {
675 remote: None,
676 refspecs: positionals.to_vec(),
677 })
678}
679
680#[must_use]
688pub fn expand_repository_shorthand(
689 config: &ConfigSet,
690 positionals: &[String],
691 host: &str,
692) -> Option<String> {
693 let [first, _, ..] = positionals else {
694 return None;
695 };
696 if has_remote_url(config, first) || looks_like_literal_remote(first) {
697 return None;
698 }
699 let is_segment = |s: &str| {
700 !s.is_empty()
701 && !s.starts_with('.')
702 && s.chars()
703 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
704 };
705 let (owner, project) = first.split_once('/')?;
706 (is_segment(owner) && is_segment(project))
707 .then(|| format!("{}/{owner}/{project}.git", host.trim_end_matches('/')))
708}
709
710pub fn open_push_destination(config: &ConfigSet, name: &str) -> Result<Remote, RemoteError> {
719 if looks_like_literal_remote(name) {
720 Remote::from_url(name)
721 } else {
722 Remote::from_config(config, name)
723 }
724}
725
726impl PushPlanContext {
727 #[must_use]
729 pub fn from_advertised_refs(refs: impl IntoIterator<Item = RemoteRef>) -> Self {
730 let mut ctx = Self::default();
731 let mut tips = Vec::new();
732 for remote_ref in refs {
733 if remote_ref.peeled_tag().is_some() {
734 continue;
735 }
736 if remote_ref.name.starts_with("refs/heads/") {
737 ctx.remote_branch_tips.insert(remote_ref.oid);
738 }
739 tips.push(remote_ref.oid);
740 ctx.remote_ref_names.insert(remote_ref.name);
741 }
742 ctx.remote_tips = Some(tips);
743 ctx
744 }
745}
746
747pub fn load_push_plan_context(
762 repo: &Repository,
763 config: &ConfigSet,
764 remote_override: Option<&str>,
765 http_factory: Option<&dyn HttpClientFactory>,
766) -> PlanResult<PushPlanContext> {
767 let remote = configured_remote_for_push(repo, config, remote_override)?;
768 let Ok(handle) = open_push_destination(config, &remote) else {
769 return Ok(PushPlanContext::default());
770 };
771 let probe = ProbeHttpClientFactory::new(http_factory, repo);
772 let Ok(advertised) = handle.list_push_refs(repo, Some(&probe)) else {
773 return Ok(PushPlanContext::default());
774 };
775 Ok(PushPlanContext::from_advertised_refs(advertised))
776}
777
778fn looks_like_literal_remote(name: &str) -> bool {
779 name.contains("://") || name.ends_with(".git") || name.starts_with('/')
780}
781
782fn has_remote_url(config: &ConfigSet, name: &str) -> bool {
783 config
784 .get(&format!("remote.{name}.url"))
785 .is_some_and(|u| !u.trim().is_empty())
786}
787
788fn validate_remote(config: &ConfigSet, name: &str) -> PlanResult<()> {
789 if has_remote_url(config, name) {
790 Ok(())
791 } else if name == DEFAULT_REMOTE || looks_like_literal_remote(name) {
792 Ok(())
794 } else {
795 Err(PushPlanError::UnknownRemote(name.to_owned()))
796 }
797}
798
799fn remote_push_refspec_strings(config: &ConfigSet, remote: &str) -> Vec<String> {
800 config
801 .get_all(&format!("remote.{remote}.push"))
802 .into_iter()
803 .filter(|s| !s.trim().is_empty())
804 .collect()
805}
806
807fn default_push_destination(
808 repo: &Repository,
809 config: &ConfigSet,
810 remote: &str,
811 branch_short: &str,
812 _src_ref: &str,
813) -> PlanResult<String> {
814 let push_default = config
815 .get("push.default")
816 .unwrap_or_else(|| "simple".to_owned());
817
818 if push_default == "nothing" {
819 return Err(PushPlanError::PushDefaultNothing);
820 }
821
822 if push_default.as_str() == "upstream" {
823 let merge = config
824 .get(&format!("branch.{branch_short}.merge"))
825 .filter(|m| m.starts_with("refs/"));
826 return merge.ok_or_else(|| {
827 PushPlanError::Message(format!(
828 "branch '{branch_short}' has no upstream for push.default upstream"
829 ))
830 });
831 }
832
833 if push_default.as_str() == "simple" {
836 let branch_remote = config.get(&format!("branch.{branch_short}.remote"));
837 if branch_remote.as_deref() == Some(remote) {
838 if let Some(merge) = config
839 .get(&format!("branch.{branch_short}.merge"))
840 .filter(|m| m.starts_with("refs/"))
841 {
842 let _merge_branch = merge.strip_prefix("refs/heads/").unwrap_or(merge.as_str());
843 let same_name_dst = format!("refs/heads/{branch_short}");
844 if merge != same_name_dst {
845 return Ok(merge);
847 }
848 return Ok(same_name_dst);
849 }
850 }
851 }
852
853 let _ = resolve_push_full_ref_for_branch(repo, branch_short);
854 Ok(format!("refs/heads/{branch_short}"))
855}
856
857fn resolve_cli_refspec(
858 repo: &Repository,
859 config: &ConfigSet,
860 remote: &str,
861 spec: &str,
862 ctx: &PushPlanContext,
863) -> PlanResult<Vec<PushRefSpec>> {
864 if !spec.contains(':') {
865 return resolve_shorthand_refspec(repo, config, remote, spec, ctx);
866 }
867 let item = parse_push_refspec(spec).map_err(|e| PushPlanError::InvalidRefspec {
868 spec: spec.to_owned(),
869 reason: e.to_string(),
870 })?;
871 resolve_parsed_refspec(repo, &item, ctx)
872}
873
874fn resolve_shorthand_refspec(
875 repo: &Repository,
876 config: &ConfigSet,
877 remote: &str,
878 spec: &str,
879 ctx: &PushPlanContext,
880) -> PlanResult<Vec<PushRefSpec>> {
881 let local_heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
882 let local_tags = list_local_refs(repo.git_dir(), "refs/tags/")?;
883 let mut all = local_heads;
884 all.extend(local_tags);
885
886 let count = count_refname_match(spec, &all);
887 if count == 1 {
888 let Some(matched) = refname_match(spec, &all) else {
889 return Err(PushPlanError::SrcRefspecNoMatch {
890 spec: spec.to_owned(),
891 });
892 };
893 if let Some(mapped) = map_through_remote_push_refspec(config, remote, &matched.0) {
894 let item = parse_push_refspec(&mapped).map_err(|e| PushPlanError::InvalidRefspec {
895 spec: mapped.clone(),
896 reason: e.to_string(),
897 })?;
898 return resolve_parsed_refspec(repo, &item, ctx);
899 }
900 let item = RefspecItem {
901 force: false,
902 src: Some(matched.0.clone()),
903 dst: Some(matched.0.clone()),
904 ..Default::default()
905 };
906 return resolve_parsed_refspec(repo, &item, ctx);
907 }
908 if count == 0 {
909 let item = parse_push_refspec(spec).map_err(|e| PushPlanError::InvalidRefspec {
911 spec: spec.to_owned(),
912 reason: e.to_string(),
913 })?;
914 return resolve_parsed_refspec(repo, &item, ctx);
915 }
916 Err(PushPlanError::SrcRefspecAmbiguous {
917 spec: spec.to_owned(),
918 })
919}
920
921fn map_through_remote_push_refspec(
922 config: &ConfigSet,
923 remote: &str,
924 local_full_ref: &str,
925) -> Option<String> {
926 for spec in remote_push_refspec_strings(config, remote) {
927 let Ok(item) = parse_push_refspec(&spec) else {
928 continue;
929 };
930 if item.negative || item.matching {
931 continue;
932 }
933 if let Some(dst) = refspec_map_src(&item, local_full_ref) {
934 let force = if item.force { "+" } else { "" };
935 return Some(format!("{force}{local_full_ref}:{dst}"));
936 }
937 }
938 None
939}
940
941fn expand_configured_push_refspec(
942 repo: &Repository,
943 spec: String,
944 ctx: &PushPlanContext,
945) -> PlanResult<Vec<PushRefSpec>> {
946 let item = parse_push_refspec(&spec).map_err(|e| PushPlanError::InvalidRefspec {
947 spec: spec.clone(),
948 reason: e.to_string(),
949 })?;
950 if item.matching {
951 let heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
952 let mut out = Vec::new();
953 for (name, oid) in heads {
954 if !ctx.remote_ref_names.contains(&name) {
955 continue;
956 }
957 out.push(push_spec(
958 Some(oid),
959 Some(name.clone()),
960 name,
961 item.force,
962 false,
963 ));
964 }
965 return Ok(out);
966 }
967 if item.pattern {
968 let prefix = item.src.as_deref().unwrap_or("");
969 let local = if prefix.starts_with("refs/tags/") {
970 list_local_refs(repo.git_dir(), "refs/tags/")?
971 } else {
972 list_local_refs(repo.git_dir(), "refs/heads/")?
973 };
974 let mut out = Vec::new();
975 for (name, _) in local {
976 if let Some(dst) = refspec_map_src(&item, &name) {
977 let oid = resolve_src_ref(repo, &name)?;
978 out.push(push_spec(
979 Some(oid),
980 Some(name.clone()),
981 dst,
982 item.force,
983 false,
984 ));
985 }
986 }
987 return Ok(out);
988 }
989 resolve_parsed_refspec(repo, &item, ctx)
990}
991
992fn resolve_parsed_refspec(
993 repo: &Repository,
994 item: &RefspecItem,
995 ctx: &PushPlanContext,
996) -> PlanResult<Vec<PushRefSpec>> {
997 if item.negative {
998 return Ok(Vec::new());
999 }
1000 if item.matching {
1001 return expand_configured_push_refspec(
1002 repo,
1003 if item.force {
1004 "+:".to_owned()
1005 } else {
1006 ":".to_owned()
1007 },
1008 ctx,
1009 );
1010 }
1011
1012 let src_empty = item.src.as_deref().is_some_and(|s| s.is_empty());
1013 let src_name = item.src.as_deref().unwrap_or("");
1014
1015 if src_empty || (src_name.is_empty() && item.dst.is_some()) {
1016 let dst = item
1017 .dst
1018 .as_ref()
1019 .ok_or_else(|| PushPlanError::InvalidRefspec {
1020 spec: String::new(),
1021 reason: "delete refspec missing destination".into(),
1022 })?;
1023 let resolved_dst = resolve_dst_name(repo, dst, None, ctx)?;
1024 return Ok(vec![push_spec(None, None, resolved_dst, item.force, true)]);
1025 }
1026
1027 let src_oid = if item.exact_sha1 {
1028 ObjectId::from_hex(src_name).map_err(|_| PushPlanError::UnknownRevision {
1029 spec: src_name.to_owned(),
1030 })?
1031 } else if src_name.is_empty() {
1032 resolve_src_ref(repo, "HEAD")?
1033 } else {
1034 match resolve_src_ref(repo, src_name) {
1035 Ok(oid) => oid,
1036 Err(_) => {
1037 let local = list_local_refs(repo.git_dir(), "refs/heads/")?;
1038 let local_tags = list_local_refs(repo.git_dir(), "refs/tags/")?;
1039 let mut all = local;
1040 all.extend(local_tags);
1041 match count_refname_match(src_name, &all) {
1042 0 => {
1043 return Err(PushPlanError::SrcRefspecNoMatch {
1044 spec: src_name.to_owned(),
1045 });
1046 }
1047 1 => {
1048 let Some((name, oid)) = refname_match(src_name, &all) else {
1049 return Err(PushPlanError::SrcRefspecNoMatch {
1050 spec: src_name.to_owned(),
1051 });
1052 };
1053 let dst = resolve_dst_for_src(repo, item, &name, ctx)?;
1054 return Ok(vec![push_spec(
1055 Some(oid),
1056 Some(name.clone()),
1057 dst,
1058 item.force,
1059 false,
1060 )]);
1061 }
1062 _ => {
1063 return Err(PushPlanError::SrcRefspecAmbiguous {
1064 spec: src_name.to_owned(),
1065 });
1066 }
1067 }
1068 }
1069 }
1070 };
1071
1072 let src_full = if item.exact_sha1 {
1073 None
1074 } else {
1075 Some(full_src_ref_name(repo, src_name)?)
1076 };
1077 let src_label = src_full.clone();
1078 let dst = resolve_dst_for_src(repo, item, src_label.as_deref().unwrap_or(src_name), ctx)?;
1079 let mut spec = push_spec(Some(src_oid), src_label, dst, item.force, false);
1080 if matches!(src_name, "HEAD" | "@") {
1081 spec.hook_src_ref = Some("HEAD".to_owned());
1082 }
1083 Ok(vec![spec])
1084}
1085
1086fn resolve_dst_for_src(
1087 repo: &Repository,
1088 item: &RefspecItem,
1089 src_full: &str,
1090 ctx: &PushPlanContext,
1091) -> PlanResult<String> {
1092 if let Some(dst) = &item.dst {
1093 if dst.is_empty() {
1094 return Err(PushPlanError::InvalidRefspec {
1095 spec: String::new(),
1096 reason: "empty destination".into(),
1097 });
1098 }
1099 return resolve_dst_name(repo, dst, Some(src_full), ctx);
1100 }
1101 if src_full.starts_with("refs/heads/") {
1103 return Ok(src_full.to_owned());
1104 }
1105 if src_full.starts_with("refs/tags/") {
1106 return Ok(src_full.to_owned());
1107 }
1108 Ok(src_full.to_owned())
1109}
1110
1111fn resolve_dst_name(
1112 _repo: &Repository,
1113 dst: &str,
1114 src_full: Option<&str>,
1115 ctx: &PushPlanContext,
1116) -> PlanResult<String> {
1117 if dst.starts_with("refs/") {
1118 return Ok(dst.to_owned());
1119 }
1120 let remote_list = remote_ref_list(ctx);
1121 match count_refname_match(dst, &remote_list) {
1122 0 => {}
1123 1 => {
1124 let Some((name, _)) = refname_match(dst, &remote_list) else {
1125 return Err(PushPlanError::InvalidRefspec {
1126 spec: dst.to_owned(),
1127 reason: "remote ref match failed".into(),
1128 });
1129 };
1130 return Ok(name);
1131 }
1132 _ => {
1133 return Err(PushPlanError::DstRefspecAmbiguous {
1134 spec: dst.to_owned(),
1135 });
1136 }
1137 }
1138 if let Some(src) = src_full {
1139 if let Some(guessed) = guess_qualified_dst(src, dst) {
1140 return Ok(guessed);
1141 }
1142 }
1143 Ok(format!("refs/heads/{dst}"))
1144}
1145
1146fn remote_ref_list(ctx: &PushPlanContext) -> Vec<(String, ObjectId)> {
1147 ctx.remote_ref_names
1148 .iter()
1149 .filter_map(|n| {
1150 ObjectId::from_hex("0".repeat(40).as_str())
1152 .ok()
1153 .map(|z| (n.clone(), z))
1154 })
1155 .collect()
1156}
1157
1158fn guess_qualified_dst(src_full: &str, short_dst: &str) -> Option<String> {
1159 if src_full.starts_with("refs/heads/") {
1160 Some(format!("refs/heads/{short_dst}"))
1161 } else if src_full.starts_with("refs/tags/") {
1162 Some(format!("refs/tags/{short_dst}"))
1163 } else {
1164 None
1165 }
1166}
1167
1168fn apply_force_mode(
1170 repo: &Repository,
1171 config: &ConfigSet,
1172 remote: &str,
1173 specs: &mut [PushRefSpec],
1174 mode: &ForceMode,
1175) -> PlanResult<()> {
1176 match mode {
1177 ForceMode::None => {}
1178 ForceMode::Force => {
1179 for spec in specs.iter_mut() {
1180 spec.force = true;
1181 spec.expected_old = None;
1182 spec.expect_absent = false;
1183 }
1184 }
1185 ForceMode::Lease(LeaseExpectation::Tracking) => {
1186 for spec in specs.iter_mut() {
1187 apply_tracking_lease(repo, config, remote, spec);
1188 }
1189 }
1190 ForceMode::Lease(LeaseExpectation::Value(expected)) => {
1191 if specs.len() != 1 {
1192 return Err(PushPlanError::LeaseValueRequiresSingleRef);
1193 }
1194 for spec in specs.iter_mut() {
1195 spec.force = true;
1196 spec.expected_old = Some(*expected);
1197 spec.expect_absent = false;
1198 }
1199 }
1200 }
1201 Ok(())
1202}
1203
1204fn apply_tracking_lease(
1205 repo: &Repository,
1206 config: &ConfigSet,
1207 remote: &str,
1208 spec: &mut PushRefSpec,
1209) {
1210 spec.force = true;
1211 spec.expected_old = None;
1212 spec.expect_absent = false;
1213 if let Some(tracking) = remote_tracking_ref_for_remote_ref(config, remote, &spec.dst) {
1214 match refs::resolve_ref(repo.git_dir(), &tracking) {
1215 Ok(oid) => spec.expected_old = Some(oid),
1216 Err(_) => spec.expect_absent = true,
1217 }
1218 } else {
1219 spec.expect_absent = true;
1220 }
1221}
1222
1223fn push_spec(
1224 src: Option<ObjectId>,
1225 src_ref: Option<String>,
1226 dst: String,
1227 force: bool,
1228 delete: bool,
1229) -> PushRefSpec {
1230 PushRefSpec {
1231 src,
1232 src_ref,
1233 dst,
1234 force,
1235 delete,
1236 ..Default::default()
1237 }
1238}
1239
1240fn resolve_src_ref(repo: &Repository, spec: &str) -> PlanResult<ObjectId> {
1241 resolve_revision_for_verify(repo, spec).map_err(|_| PushPlanError::UnknownRevision {
1242 spec: spec.to_owned(),
1243 })
1244}
1245
1246fn full_src_ref_name(repo: &Repository, spec: &str) -> PlanResult<String> {
1247 if spec.eq_ignore_ascii_case("HEAD") {
1248 if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
1249 return Ok(format!("refs/heads/{short_name}"));
1250 }
1251 }
1252 if spec.starts_with("refs/") {
1253 return Ok(spec.to_owned());
1254 }
1255 let heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
1256 if let Some((name, _)) = refname_match(spec, &heads) {
1257 return Ok(name);
1258 }
1259 let tags = list_local_refs(repo.git_dir(), "refs/tags/")?;
1260 if let Some((name, _)) = refname_match(spec, &tags) {
1261 return Ok(name);
1262 }
1263 Ok(spec.to_owned())
1264}
1265
1266fn list_local_refs(git_dir: &Path, prefix: &str) -> PlanResult<Vec<(String, ObjectId)>> {
1267 refs::list_refs(git_dir, prefix).map_err(|e| PushPlanError::Message(e.to_string()))
1268}
1269
1270fn count_refname_match(pattern: &str, refs: &[(String, ObjectId)]) -> usize {
1272 let mut strong = 0usize;
1273 let mut weak = 0usize;
1274 let patlen = pattern.len();
1275 for (name, _) in refs {
1276 if !refname_match_one(pattern, name) {
1277 continue;
1278 }
1279 let namelen = name.len();
1280 if namelen != patlen
1281 && patlen != namelen.saturating_sub(5)
1282 && !name.starts_with("refs/heads/")
1283 && !name.starts_with("refs/tags/")
1284 {
1285 weak += 1;
1286 } else {
1287 strong += 1;
1288 }
1289 }
1290 if strong == 0 {
1291 weak
1292 } else {
1293 strong
1294 }
1295}
1296
1297fn refname_match(pattern: &str, refs: &[(String, ObjectId)]) -> Option<(String, ObjectId)> {
1298 let mut strong: Option<(String, ObjectId)> = None;
1299 let mut weak: Option<(String, ObjectId)> = None;
1300 let patlen = pattern.len();
1301 for (name, oid) in refs {
1302 if !refname_match_one(pattern, name) {
1303 continue;
1304 }
1305 let namelen = name.len();
1306 if namelen != patlen
1307 && patlen != namelen.saturating_sub(5)
1308 && !name.starts_with("refs/heads/")
1309 && !name.starts_with("refs/tags/")
1310 {
1311 weak = Some((name.clone(), *oid));
1312 } else {
1313 strong = Some((name.clone(), *oid));
1314 }
1315 }
1316 strong.or(weak)
1317}
1318
1319fn refname_match_one(pattern: &str, refname: &str) -> bool {
1320 if pattern == refname {
1321 return true;
1322 }
1323 if refname.ends_with(pattern) {
1324 if refname == format!("refs/heads/{pattern}") || refname == format!("refs/tags/{pattern}") {
1326 return true;
1327 }
1328 if pattern.len() + 5 == refname.len() && refname.starts_with("refs/heads/") {
1329 return refname.strip_prefix("refs/heads/") == Some(pattern);
1330 }
1331 if pattern.len() + 5 == refname.len() && refname.starts_with("refs/tags/") {
1332 return refname.strip_prefix("refs/tags/") == Some(pattern);
1333 }
1334 }
1335 false
1336}
1337
1338fn refspec_map_src(item: &RefspecItem, src_ref: &str) -> Option<String> {
1339 let src_pat = item.src.as_deref()?;
1340 let dst_pat = item.dst.as_deref()?;
1341 match_refname_with_pattern(src_pat, src_ref, dst_pat)
1342}
1343
1344fn push_spec_excluded_by_negative(item: &RefspecItem, spec: &PushRefSpec) -> bool {
1346 if !item.negative {
1347 return false;
1348 }
1349 let Some(pat) = item.src.as_deref() else {
1350 return false;
1351 };
1352 let local = spec.src_ref.as_deref().or_else(|| {
1353 spec.dst
1354 .starts_with("refs/heads/")
1355 .then_some(spec.dst.as_str())
1356 });
1357 let Some(local) = local else {
1358 return false;
1359 };
1360 if item.pattern {
1361 return match_refname_with_pattern(pat, local, pat).is_some();
1362 }
1363 pat == local || refname_match_one(pat, local)
1364}
1365
1366fn match_refname_with_pattern(pattern: &str, refname: &str, replacement: &str) -> Option<String> {
1367 match (pattern.find('*'), replacement.find('*')) {
1368 (Some(pstar), Some(vstar)) => {
1369 let kprefix = &pattern[..pstar];
1370 let ksuffix = &pattern[pstar + 1..];
1371 if !refname.starts_with(kprefix) || !refname.ends_with(ksuffix) {
1372 return None;
1373 }
1374 if refname.len() < kprefix.len() + ksuffix.len() {
1375 return None;
1376 }
1377 let middle = &refname[kprefix.len()..refname.len() - ksuffix.len()];
1378 Some(format!(
1379 "{}{}{}",
1380 &replacement[..vstar],
1381 middle,
1382 &replacement[vstar + 1..]
1383 ))
1384 }
1385 (None, None) if pattern == refname => Some(replacement.to_owned()),
1386 _ => None,
1387 }
1388}
1389
1390fn delete_branch_label(specs: &[PushRefSpec]) -> Option<String> {
1391 if specs.len() != 1 {
1392 return None;
1393 }
1394 specs[0].dst.strip_prefix("refs/heads/").map(str::to_owned)
1395}
1396
1397fn branch_label_from_specs(_repo: &Repository, specs: &[PushRefSpec]) -> Option<String> {
1398 if specs.len() != 1 || specs[0].delete {
1399 return None;
1400 }
1401 let dst = &specs[0].dst;
1402 dst.strip_prefix("refs/heads/")
1403 .or_else(|| dst.strip_prefix("refs/tags/"))
1404 .map(str::to_owned)
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use super::*;
1410 use crate::config::environment::Environment;
1411 use crate::repo::{InitOptions, Repository};
1412
1413 fn init_repo() -> (tempfile::TempDir, Repository) {
1414 let tmp = tempfile::tempdir().expect("tempdir");
1415 let repo = Repository::init(tmp.path(), &InitOptions::default()).expect("init");
1416 (tmp, repo)
1417 }
1418
1419 #[test]
1420 fn push_remote_precedence_pushremote_over_remote() {
1421 let (_tmp, repo) = init_repo();
1422 let git_dir = repo.git_dir();
1423 std::fs::write(
1424 git_dir.join("config"),
1425 r#"[remote "origin"]
1426 url = /tmp/o.git
1427[remote "upstream"]
1428 url = /tmp/u.git
1429[branch "main"]
1430 remote = origin
1431 pushRemote = upstream
1432"#,
1433 )
1434 .expect("write config");
1435 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
1436 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1437 let remote = configured_remote_for_push(&repo, &config, None).expect("remote");
1438 assert_eq!(remote, "upstream");
1439 }
1440
1441 #[test]
1442 fn map_through_skips_malformed_remote_push_refspec() {
1443 let (_tmp, repo) = init_repo();
1444 let git_dir = repo.git_dir();
1445 std::fs::write(
1446 git_dir.join("config"),
1447 r#"[remote "origin"]
1448 url = /tmp/o.git
1449 push = not a valid refspec at all
1450 push = refs/heads/*:refs/remotes/origin/*
1451"#,
1452 )
1453 .expect("write config");
1454 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
1455 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1456 let mapped = map_through_remote_push_refspec(&config, "origin", "refs/heads/main");
1457 assert_eq!(
1458 mapped.as_deref(),
1459 Some("refs/heads/main:refs/remotes/origin/main")
1460 );
1461 }
1462
1463 #[test]
1464 fn force_with_lease_tracking_sets_expected_old() {
1465 let (_tmp, repo) = init_repo();
1466 let git_dir = repo.git_dir();
1467 std::fs::write(
1468 git_dir.join("config"),
1469 r#"[remote "origin"]
1470 url = /tmp/o.git
1471 fetch = +refs/heads/*:refs/remotes/origin/*
1472[branch "main"]
1473 remote = origin
1474 merge = refs/heads/main
1475"#,
1476 )
1477 .expect("config");
1478 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
1479 let oid = ObjectId::from_hex("a".repeat(40).as_str()).expect("oid");
1480 std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads dir");
1481 std::fs::write(
1482 git_dir.join("refs/heads/main"),
1483 format!("{}\n", oid.to_hex()),
1484 )
1485 .expect("main");
1486 std::fs::create_dir_all(git_dir.join("refs/remotes/origin")).expect("rt dir");
1487 std::fs::write(
1488 git_dir.join("refs/remotes/origin/main"),
1489 format!("{}\n", oid.to_hex()),
1490 )
1491 .expect("tracking");
1492 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1493 let ctx = PushPlanContext::default();
1494 let resolved = resolve_push(
1495 &repo,
1496 &config,
1497 &PushRequest {
1498 force: ForceMode::Lease(LeaseExpectation::Tracking),
1499 ..Default::default()
1500 },
1501 &ctx,
1502 )
1503 .expect("resolve");
1504 assert_eq!(resolved.specs.len(), 1);
1505 assert_eq!(resolved.specs[0].expected_old, Some(oid));
1506 assert!(resolved.specs[0].force);
1507 }
1508
1509 #[test]
1510 fn negative_push_refspec_filters_planned_spec() {
1511 let item = parse_push_refspec("^refs/heads/private").expect("parse");
1512 let kept = push_spec(
1513 Some(ObjectId::from_hex("0".repeat(40).as_str()).expect("oid")),
1514 Some("refs/heads/main".into()),
1515 "refs/heads/main".into(),
1516 false,
1517 false,
1518 );
1519 let dropped = push_spec(
1520 Some(ObjectId::from_hex("1".repeat(40).as_str()).expect("oid")),
1521 Some("refs/heads/private".into()),
1522 "refs/heads/private".into(),
1523 false,
1524 false,
1525 );
1526 assert!(!push_spec_excluded_by_negative(&item, &kept));
1527 assert!(push_spec_excluded_by_negative(&item, &dropped));
1528 }
1529
1530 fn config_from(text: &str) -> ConfigSet {
1531 let file = crate::config::ConfigFile::parse(
1532 Path::new("config"),
1533 text,
1534 crate::config::ConfigScope::Local,
1535 )
1536 .expect("parse config");
1537 let mut config = ConfigSet::new();
1538 config.merge(&file);
1539 config
1540 }
1541
1542 fn strings(items: &[&str]) -> Vec<String> {
1543 items.iter().map(|s| (*s).to_owned()).collect()
1544 }
1545
1546 #[test]
1547 fn push_invocation_remote_flag_keeps_every_positional_as_refspec() {
1548 let config = ConfigSet::new();
1549 let invocation =
1550 resolve_push_invocation(&config, Some("upstream"), &strings(&["origin", "main"]))
1551 .expect("invocation");
1552 assert_eq!(
1553 invocation,
1554 PushInvocation {
1555 remote: Some("upstream".into()),
1556 refspecs: strings(&["origin", "main"]),
1557 }
1558 );
1559 }
1560
1561 #[test]
1562 fn push_invocation_without_positionals_uses_default_remote() {
1563 let invocation = resolve_push_invocation(&ConfigSet::new(), None, &[]).expect("invocation");
1564 assert_eq!(invocation, PushInvocation::default());
1565 }
1566
1567 #[test]
1568 fn push_invocation_first_positional_is_configured_remote() {
1569 let config = config_from("[remote \"upstream\"]\n\turl = /tmp/u.git\n");
1570 for positionals in [strings(&["upstream"]), strings(&["upstream", "a", "b:c"])] {
1571 let invocation =
1572 resolve_push_invocation(&config, None, &positionals).expect("invocation");
1573 assert_eq!(invocation.remote.as_deref(), Some("upstream"));
1574 assert_eq!(invocation.refspecs, positionals[1..]);
1575 }
1576 }
1577
1578 #[test]
1579 fn push_invocation_first_positional_is_literal_url_or_path() {
1580 let config = ConfigSet::new();
1581 for remote in ["https://example.com/r", "../sibling.git", "/srv/repo"] {
1582 let invocation = resolve_push_invocation(&config, None, &strings(&[remote, "main"]))
1583 .expect("invocation");
1584 assert_eq!(invocation.remote.as_deref(), Some(remote));
1585 assert_eq!(invocation.refspecs, strings(&["main"]));
1586 }
1587 }
1588
1589 #[test]
1590 fn push_invocation_single_unknown_positional_is_a_refspec() {
1591 let config = config_from("[remote \"blank\"]\n\turl = \n");
1592 for token in ["main", "HEAD:refs/heads/x", "blank"] {
1593 let invocation =
1594 resolve_push_invocation(&config, None, &strings(&[token])).expect("invocation");
1595 assert_eq!(invocation.remote, None, "{token}");
1596 assert_eq!(invocation.refspecs, strings(&[token]));
1597 }
1598 }
1599
1600 #[test]
1601 fn push_without_any_remote_is_no_remote() {
1602 let (_tmp, repo) = init_repo();
1603 assert_eq!(
1604 configured_remote_for_push(&repo, &ConfigSet::new(), None),
1605 Err(PushPlanError::NoRemote)
1606 );
1607 assert_eq!(PushPlanError::NoRemote.to_string(), "no remote to push to");
1608 assert_eq!(
1609 load_push_plan_context(&repo, &ConfigSet::new(), None, None).map(|_| ()),
1610 Err(PushPlanError::NoRemote)
1611 );
1612 let named = configured_remote_for_push(&repo, &ConfigSet::new(), Some("upstream"));
1613 assert_eq!(named.as_deref(), Ok("upstream"));
1614 let url_less_origin = config_from("[remote \"origin\"]\n\tpushurl = /tmp/o.git\n");
1615 assert_eq!(
1616 configured_remote_for_push(&repo, &url_less_origin, None).as_deref(),
1617 Ok("origin")
1618 );
1619 }
1620
1621 #[test]
1622 fn push_invocation_unknown_remote_before_refspecs_is_rejected() {
1623 let err = resolve_push_invocation(&ConfigSet::new(), None, &strings(&["nosuch", "main"]))
1624 .expect_err("unknown remote");
1625 assert_eq!(err, PushPlanError::UnknownRemote("nosuch".into()));
1626 }
1627
1628 #[test]
1629 fn owner_project_shorthand_expands_to_host_url() {
1630 let config = config_from("[remote \"team/app\"]\n\turl = /srv/app.git\n");
1631 let expand = |args: &[&str]| {
1632 let args: Vec<String> = args.iter().map(|s| (*s).to_owned()).collect();
1633 expand_repository_shorthand(&config, &args, "https://grithub.dev/")
1634 };
1635 assert_eq!(
1636 expand(&["me/proj", "main"]).as_deref(),
1637 Some("https://grithub.dev/me/proj.git")
1638 );
1639 assert_eq!(expand(&["topic/x"]), None);
1641 for literal in [
1643 "team/app",
1644 "origin",
1645 "./a/b",
1646 "a/b/c",
1647 "x/y.git",
1648 "https://h/a/b",
1649 ] {
1650 assert_eq!(expand(&[literal, "main"]), None, "{literal}");
1651 }
1652 }
1653
1654 #[test]
1655 fn open_push_destination_reads_config_or_treats_literal_as_url() {
1656 let config = config_from(
1657 "[remote \"origin\"]\n\turl = /tmp/o.git\n[remote \"nourl\"]\n\tfetch = +refs/heads/*:refs/remotes/nourl/*\n",
1658 );
1659 let origin = open_push_destination(&config, "origin").expect("origin");
1660 assert_eq!(origin.name.as_deref(), Some("origin"));
1661 let literal = open_push_destination(&config, "/srv/other.git").expect("literal");
1662 assert_eq!(literal.name, None);
1663 assert!(matches!(
1664 open_push_destination(&config, "nourl"),
1665 Err(RemoteError::NoUrl { remote: Some(name) }) if name == "nourl"
1666 ));
1667 }
1668
1669 #[test]
1670 fn plan_context_from_advertisement_skips_peeled_entries() {
1671 let oid = |c: &str| ObjectId::from_hex(&c.repeat(40)).expect("oid");
1672 let remote_ref = |name: &str, hex: &str| RemoteRef {
1673 name: name.to_owned(),
1674 oid: oid(hex),
1675 symref_target: None,
1676 };
1677 let ctx = PushPlanContext::from_advertised_refs([
1678 remote_ref("HEAD", "1"),
1679 remote_ref("refs/heads/main", "1"),
1680 remote_ref("refs/heads/topic", "2"),
1681 remote_ref("refs/tags/v1", "3"),
1682 remote_ref("refs/tags/v1^{}", "4"),
1683 ]);
1684 let mut names: Vec<_> = ctx.remote_ref_names.iter().map(String::as_str).collect();
1685 names.sort_unstable();
1686 assert_eq!(
1687 names,
1688 [
1689 "HEAD",
1690 "refs/heads/main",
1691 "refs/heads/topic",
1692 "refs/tags/v1"
1693 ]
1694 );
1695 assert_eq!(ctx.remote_branch_tips, HashSet::from([oid("1"), oid("2")]));
1696 assert_eq!(
1697 ctx.remote_tips,
1698 Some(vec![oid("1"), oid("1"), oid("2"), oid("3")])
1699 );
1700 }
1701
1702 #[test]
1703 fn plan_context_from_empty_advertisement_knows_remote_has_no_tips() {
1704 let ctx = PushPlanContext::from_advertised_refs([]);
1705 assert_eq!(ctx.remote_tips, Some(Vec::new()));
1706 }
1707
1708 #[test]
1709 fn plan_context_is_empty_for_unconfigured_remote() {
1710 let (_tmp, repo) = init_repo();
1711 let ctx = load_push_plan_context(&repo, &ConfigSet::new(), Some("nosuch"), None)
1712 .expect("context");
1713 assert!(ctx.remote_ref_names.is_empty());
1714 assert!(ctx.remote_branch_tips.is_empty());
1715 assert_eq!(ctx.remote_tips, None);
1716 }
1717
1718 #[test]
1719 fn plan_context_for_missing_path_assumes_no_remote_objects() {
1720 let (tmp, repo) = init_repo();
1721 let missing = tmp.path().join("missing.git");
1722 let missing = missing.to_str().expect("utf-8 path");
1723 let ctx =
1724 load_push_plan_context(&repo, &ConfigSet::new(), Some(missing), None).expect("context");
1725 assert!(ctx.remote_ref_names.is_empty());
1726 assert!(ctx.remote_tips.as_ref().is_none_or(Vec::is_empty));
1727 }
1728
1729 #[test]
1730 fn tag_push_branch_label_is_tag_name_not_current_branch() {
1731 let (_tmp, repo) = init_repo();
1732 let git_dir = repo.git_dir();
1733 std::fs::write(
1734 git_dir.join("config"),
1735 r#"[remote "origin"]
1736 url = /tmp/o.git
1737"#,
1738 )
1739 .expect("config");
1740 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
1741 let oid = ObjectId::from_hex("a".repeat(40).as_str()).expect("oid");
1742 std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads");
1743 std::fs::write(
1744 git_dir.join("refs/heads/main"),
1745 format!("{}\n", oid.to_hex()),
1746 )
1747 .expect("main");
1748 std::fs::create_dir_all(git_dir.join("refs/tags")).expect("tags");
1749 std::fs::write(git_dir.join("refs/tags/v1"), format!("{}\n", oid.to_hex())).expect("tag");
1750 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1751 let ctx = PushPlanContext::default();
1752 let resolved = resolve_push(
1753 &repo,
1754 &config,
1755 &PushRequest {
1756 remote: Some("origin".to_owned()),
1757 refspecs: vec!["v1".to_owned()],
1758 ..Default::default()
1759 },
1760 &ctx,
1761 )
1762 .expect("resolve tag push");
1763 assert_eq!(resolved.branch_label.as_deref(), Some("v1"));
1764 assert_eq!(resolved.kind, PushPlanKind::Refspecs);
1765 assert_eq!(resolved.specs[0].dst, "refs/tags/v1");
1766 }
1767
1768 fn delete_request(refspecs: &[&str]) -> PushRequest {
1769 PushRequest {
1770 remote: Some("origin".to_owned()),
1771 refspecs: strings(refspecs),
1772 delete_refs: true,
1773 ..Default::default()
1774 }
1775 }
1776
1777 #[test]
1778 fn delete_push_plans_every_plain_name() {
1779 let (_tmp, repo) = init_repo();
1780 let config = config_from("[remote \"origin\"]\n\turl = /tmp/o.git\n");
1781 let ctx = PushPlanContext {
1782 remote_ref_names: HashSet::from(["refs/tags/v1".to_owned()]),
1783 ..Default::default()
1784 };
1785 let resolved = resolve_push(
1786 &repo,
1787 &config,
1788 &delete_request(&["a", "refs/heads/b", "v1", "+c", "HEAD"]),
1789 &ctx,
1790 )
1791 .expect("resolve delete push");
1792 assert_eq!(resolved.kind, PushPlanKind::Delete);
1793 assert_eq!(resolved.branch_label, None);
1794 let dsts: Vec<_> = resolved.specs.iter().map(|s| s.dst.as_str()).collect();
1795 assert_eq!(
1796 dsts,
1797 [
1798 "refs/heads/a",
1799 "refs/heads/b",
1800 "refs/tags/v1",
1801 "refs/heads/+c",
1802 "refs/heads/HEAD"
1803 ]
1804 );
1805 assert!(resolved
1806 .specs
1807 .iter()
1808 .all(|s| s.delete && !s.force && s.src.is_none()));
1809 }
1810
1811 #[test]
1812 fn delete_push_unions_delete_list_with_refspecs() {
1813 let (_tmp, repo) = init_repo();
1814 let config = config_from("[remote \"origin\"]\n\turl = /tmp/o.git\n");
1815 let request = PushRequest {
1816 delete: strings(&["a"]),
1817 ..delete_request(&["b"])
1818 };
1819 let resolved = resolve_push(&repo, &config, &request, &PushPlanContext::default())
1820 .expect("resolve delete push");
1821 let dsts: Vec<_> = resolved.specs.iter().map(|s| s.dst.as_str()).collect();
1822 assert_eq!(dsts, ["refs/heads/a", "refs/heads/b"]);
1823 assert_eq!(
1824 resolve_push(
1825 &repo,
1826 &config,
1827 &PushRequest {
1828 delete_refs: false,
1829 ..request
1830 },
1831 &PushPlanContext::default(),
1832 )
1833 .map(|r| r.specs.len()),
1834 Ok(2)
1835 );
1836 }
1837
1838 #[test]
1839 fn delete_push_rejects_colon_refspec() {
1840 let (_tmp, repo) = init_repo();
1841 let config = config_from("[remote \"origin\"]\n\turl = /tmp/o.git\n");
1842 for spec in ["HEAD:refs/heads/release", ":b", "a:"] {
1843 let err = resolve_push(
1844 &repo,
1845 &config,
1846 &delete_request(&["ok", spec]),
1847 &PushPlanContext::default(),
1848 )
1849 .expect_err("colon refspec with --delete");
1850 assert_eq!(
1851 err,
1852 PushPlanError::DeleteNotPlainName {
1853 spec: spec.to_owned()
1854 }
1855 );
1856 assert!(err.to_string().contains(spec), "{err}");
1857 }
1858 }
1859
1860 #[test]
1861 fn delete_push_without_refs_is_an_error() {
1862 let (_tmp, repo) = init_repo();
1863 let err = resolve_push(
1864 &repo,
1865 &ConfigSet::new(),
1866 &delete_request(&[]),
1867 &PushPlanContext::default(),
1868 )
1869 .expect_err("empty delete");
1870 assert_eq!(err, PushPlanError::DeleteWithoutRefs);
1871 }
1872}