1use std::collections::{HashMap, 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::gitmodules::gitdir::is_git_directory;
18use crate::config::{parse_git_config_maybe_bool, ConfigSet};
19use crate::merge_base::is_ancestor;
20use crate::objects::{ObjectId, ObjectKind};
21use crate::push_report::PushRefResult;
22use crate::refs;
23use crate::refspec::{parse_push_refspec, RefspecItem};
24use crate::remote::{HttpClientFactory, ProbeHttpClientFactory, Remote, RemoteError, RemoteRef};
25use crate::repo::Repository;
26use crate::repo_handles::has_remote_section;
27use crate::rev_parse::{resolve_push_full_ref_for_branch, resolve_revision_for_verify};
28use crate::state::{resolve_head, HeadState};
29use crate::transfer::{resolve_push_delete_ref, PushRefSpec};
30
31pub use crate::remote::DEFAULT_REMOTE;
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub enum ForceMode {
36 #[default]
38 None,
39 Force,
41 Lease(LeaseExpectation),
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum LeaseExpectation {
48 Tracking,
50 Value(ObjectId),
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
56pub struct PushRequest {
57 pub remote: Option<String>,
59 pub refspecs: Vec<String>,
61 pub tags: bool,
63 pub delete_refs: bool,
68 pub delete: Vec<String>,
71 pub force: ForceMode,
73 pub follow_tags: bool,
75 pub set_upstream: bool,
77}
78
79#[derive(Debug, Clone)]
81pub struct ResolvedPush {
82 pub remote: String,
84 pub specs: Vec<PushRefSpec>,
86 pub branch_label: Option<String>,
88 pub kind: PushPlanKind,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum PushPlanKind {
95 Branch,
97 Tags,
99 Delete,
101 Refspecs,
103}
104
105#[derive(Debug, Clone, Default)]
107pub struct PushPlanContext {
108 pub remote_ref_names: HashSet<String>,
110 pub remote_branch_tips: HashSet<ObjectId>,
113 pub remote_tips: Option<Vec<ObjectId>>,
120}
121
122#[derive(Debug, Error, PartialEq, Eq)]
124pub enum PushPlanError {
125 #[error("HEAD is detached; specify a refspec (for example HEAD:refs/heads/<name>)")]
127 DetachedHeadNoRefspec,
128 #[error("no commits yet to push")]
130 NoCommits,
131 #[error("no tags to push")]
133 NoTags,
134 #[error("push.default is nothing; no push destination")]
136 PushDefaultNothing,
137 #[error("no remote to push to")]
140 NoRemote,
141 #[error("unknown remote '{0}'")]
143 UnknownRemote(String),
144 #[error("invalid refspec '{spec}': {reason}")]
146 InvalidRefspec {
147 spec: String,
149 reason: String,
151 },
152 #[error("src refspec {spec} does not match any ref")]
154 SrcRefspecNoMatch {
155 spec: String,
157 },
158 #[error("src refspec {spec} matches more than one ref")]
160 SrcRefspecAmbiguous {
161 spec: String,
163 },
164 #[error("dst refspec {spec} matches more than one remote ref")]
166 DstRefspecAmbiguous {
167 spec: String,
169 },
170 #[error("could not resolve revision '{spec}'")]
172 UnknownRevision {
173 spec: String,
175 },
176 #[error("--force-with-lease=<rev> requires pushing exactly one ref")]
178 LeaseValueRequiresSingleRef,
179 #[error("cannot delete '{spec}': --delete takes ref names, not src:dst refspecs")]
181 DeleteNotPlainName {
182 spec: String,
184 },
185 #[error("--delete needs at least one ref to delete")]
187 DeleteWithoutRefs,
188 #[error("{0}")]
190 Message(String),
191}
192
193type PlanResult<T> = std::result::Result<T, PushPlanError>;
194
195pub fn parse_force_mode(
197 repo: &Repository,
198 force: bool,
199 force_with_lease: Option<Option<String>>,
200) -> PlanResult<ForceMode> {
201 if force {
202 return Ok(ForceMode::Force);
203 }
204 let Some(flag) = force_with_lease else {
205 return Ok(ForceMode::None);
206 };
207 match flag {
208 None => Ok(ForceMode::Lease(LeaseExpectation::Tracking)),
209 Some(rev) => {
210 let oid = crate::rev_parse::resolve_revision_for_verify(repo, &rev)
211 .map_err(|_| PushPlanError::UnknownRevision { spec: rev.clone() })?;
212 Ok(ForceMode::Lease(LeaseExpectation::Value(oid)))
213 }
214 }
215}
216
217pub fn resolve_push(
231 repo: &Repository,
232 config: &ConfigSet,
233 request: &PushRequest,
234 ctx: &PushPlanContext,
235) -> PlanResult<ResolvedPush> {
236 if request.delete_refs || !request.delete.is_empty() {
237 return resolve_delete_push(repo, config, request, ctx);
238 }
239 if request.tags {
240 return resolve_tags_push(repo, config, request);
241 }
242
243 let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
244 validate_remote(config, &remote)?;
245
246 let follow_tags = request.follow_tags || push_follow_tags_config(config);
247
248 let mut resolved = if request.refspecs.is_empty() {
249 resolve_default_push(repo, config, &remote, ctx)?
250 } else {
251 let local = LocalPushRefs::load(repo)?;
252 let mut specs = Vec::new();
253 for spec in &request.refspecs {
254 specs.extend(resolve_cli_refspec(
255 repo, config, &remote, spec, ctx, &local,
256 )?);
257 }
258 let branch_label = branch_label_from_specs(repo, &specs);
259 ResolvedPush {
260 remote,
261 specs,
262 branch_label,
263 kind: PushPlanKind::Refspecs,
264 }
265 };
266
267 if follow_tags {
268 let extra = follow_tag_push_specs(repo, &resolved.specs, ctx)?;
269 if !extra.is_empty() {
270 resolved.specs.extend(extra);
271 resolved.kind = PushPlanKind::Refspecs;
272 resolved.branch_label = None;
273 }
274 }
275
276 apply_force_mode(
277 repo,
278 config,
279 &resolved.remote,
280 &mut resolved.specs,
281 &request.force,
282 )?;
283 Ok(resolved)
284}
285
286#[must_use]
288pub fn push_follow_tags_config(config: &ConfigSet) -> bool {
289 config
290 .get("push.followTags")
291 .and_then(|v| parse_git_config_maybe_bool(&v))
292 .unwrap_or(false)
293}
294
295pub fn follow_tag_push_specs(
301 repo: &Repository,
302 specs: &[PushRefSpec],
303 ctx: &PushPlanContext,
304) -> PlanResult<Vec<PushRefSpec>> {
305 let tips = push_follow_tip_commits(repo, specs)?;
306 if tips.is_empty() {
307 return Ok(Vec::new());
308 }
309
310 let existing: HashSet<&str> = specs.iter().map(|s| s.dst.as_str()).collect();
311 let odb = repo.odb();
312 let mut out = Vec::new();
313 for (tag_name, tag_oid) in list_local_refs(repo.git_dir(), "refs/tags/")? {
314 if ctx.remote_ref_names.contains(&tag_name) || existing.contains(tag_name.as_str()) {
315 continue;
316 }
317 let Ok(obj) = odb.read(&tag_oid) else {
318 continue;
319 };
320 if obj.kind != ObjectKind::Tag {
321 continue;
322 }
323 let Ok((peeled, _)) = odb.peel_tags(tag_oid) else {
324 continue;
325 };
326 if !tag_reachable_from_push_tips(repo, peeled, &tips)? {
327 continue;
328 }
329 out.push(push_spec(
330 Some(tag_oid),
331 Some(tag_name.clone()),
332 tag_name,
333 false,
334 false,
335 ));
336 }
337 Ok(out)
338}
339
340pub fn apply_set_upstream_after_push(
345 repo: &Repository,
346 remote: &str,
347 set_upstream: bool,
348 dry_run: bool,
349 specs: &[PushRefSpec],
350 results: &[PushRefResult],
351) -> Result<Vec<String>, crate::error::Error> {
352 if !set_upstream || dry_run {
353 return Ok(Vec::new());
354 }
355 apply_push_set_upstream(repo, remote, specs, results)
356}
357
358fn push_follow_tip_commits(repo: &Repository, specs: &[PushRefSpec]) -> PlanResult<Vec<ObjectId>> {
359 let mut tips = Vec::new();
360 for spec in specs {
361 if spec.delete || is_tag_push_spec(repo, spec) {
362 continue;
363 }
364 if let Some(commit) = peel_push_src_to_commit(repo, spec) {
365 tips.push(commit);
366 }
367 }
368 Ok(tips)
369}
370
371fn is_tag_push_spec(repo: &Repository, spec: &PushRefSpec) -> bool {
372 if spec.dst.starts_with("refs/tags/") {
373 return true;
374 }
375 let Some(src) = spec.src else {
376 return false;
377 };
378 let Ok(obj) = repo.odb().read(&src) else {
379 return false;
380 };
381 obj.kind == ObjectKind::Tag
382}
383
384fn peel_push_src_to_commit(repo: &Repository, spec: &PushRefSpec) -> Option<ObjectId> {
385 let src = spec.src?;
386 let odb = repo.odb();
387 let Ok(obj) = odb.read(&src) else {
388 return None;
389 };
390 match obj.kind {
391 ObjectKind::Commit => Some(src),
392 ObjectKind::Tag => odb.peel_tags(src).ok().map(|(commit, _)| commit),
393 _ => None,
394 }
395}
396
397fn tag_reachable_from_push_tips(
398 repo: &Repository,
399 peeled: ObjectId,
400 tips: &[ObjectId],
401) -> PlanResult<bool> {
402 for &tip in tips {
403 if peeled == tip {
404 return Ok(true);
405 }
406 if is_ancestor(repo, peeled, tip).map_err(|e| PushPlanError::Message(e.to_string()))? {
407 return Ok(true);
408 }
409 }
410 Ok(false)
411}
412
413fn resolve_delete_push(
414 repo: &Repository,
415 config: &ConfigSet,
416 request: &PushRequest,
417 ctx: &PushPlanContext,
418) -> PlanResult<ResolvedPush> {
419 let names: Vec<&String> = request.delete.iter().chain(&request.refspecs).collect();
420 if let Some(spec) = names.iter().find(|name| name.contains(':')) {
421 return Err(PushPlanError::DeleteNotPlainName {
422 spec: (*spec).clone(),
423 });
424 }
425 if names.is_empty() {
426 return Err(PushPlanError::DeleteWithoutRefs);
427 }
428 let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
429 validate_remote(config, &remote)?;
430 let specs = names
431 .into_iter()
432 .map(|name| {
433 let dst = resolve_push_delete_ref(repo.git_dir(), &ctx.remote_ref_names, name);
434 push_spec(None, None, dst, false, true)
435 })
436 .collect::<Vec<_>>();
437 let branch_label = delete_branch_label(&specs);
438 let mut resolved = ResolvedPush {
439 remote,
440 specs,
441 branch_label,
442 kind: PushPlanKind::Delete,
443 };
444 apply_force_mode(
445 repo,
446 config,
447 &resolved.remote,
448 &mut resolved.specs,
449 &request.force,
450 )?;
451 Ok(resolved)
452}
453
454fn resolve_tags_push(
455 repo: &Repository,
456 config: &ConfigSet,
457 request: &PushRequest,
458) -> PlanResult<ResolvedPush> {
459 let remote = configured_remote_for_push(repo, config, request.remote.as_deref())?;
460 validate_remote(config, &remote)?;
461 let entries = refs::list_refs(repo.git_dir(), "refs/tags/")
462 .map_err(|e| PushPlanError::Message(e.to_string()))?;
463 if entries.is_empty() {
464 return Err(PushPlanError::NoTags);
465 }
466 let specs = entries
467 .into_iter()
468 .map(|(refname, oid)| push_spec(Some(oid), Some(refname.clone()), refname, false, false))
469 .collect();
470 let mut resolved = ResolvedPush {
471 remote,
472 specs,
473 branch_label: None,
474 kind: PushPlanKind::Tags,
475 };
476 apply_force_mode(
477 repo,
478 config,
479 &resolved.remote,
480 &mut resolved.specs,
481 &request.force,
482 )?;
483 Ok(resolved)
484}
485
486fn resolve_default_push(
487 repo: &Repository,
488 config: &ConfigSet,
489 remote: &str,
490 ctx: &PushPlanContext,
491) -> PlanResult<ResolvedPush> {
492 let push_specs = remote_push_refspec_strings(config, remote);
493 if !push_specs.is_empty() {
494 let mut positive_specs = Vec::new();
495 let mut negative_items = Vec::new();
496 for spec in push_specs {
497 let item = parse_push_refspec(&spec).map_err(|e| PushPlanError::InvalidRefspec {
498 spec: spec.clone(),
499 reason: e.to_string(),
500 })?;
501 if item.negative {
502 negative_items.push(item);
503 } else {
504 positive_specs.push(spec);
505 }
506 }
507 let local = LocalPushRefs::load(repo)?;
508 let mut specs = Vec::new();
509 for spec in positive_specs {
510 specs.extend(expand_configured_push_refspec(repo, spec, ctx, &local)?);
511 }
512 for item in negative_items {
513 specs.retain(|s| !push_spec_excluded_by_negative(&item, s));
514 }
515 let branch_label = branch_label_from_specs(repo, &specs);
516 let kind = if specs.len() == 1 {
517 PushPlanKind::Branch
518 } else {
519 PushPlanKind::Refspecs
520 };
521 return Ok(ResolvedPush {
522 remote: remote.to_owned(),
523 specs,
524 branch_label,
525 kind,
526 });
527 }
528
529 let push_default = config
530 .get("push.default")
531 .unwrap_or_else(|| "simple".to_owned());
532 if push_default == "matching" {
533 return resolve_matching_default_push(repo, remote, ctx);
534 }
535
536 let (short_name, oid) = current_branch_oid(repo)?;
537 let src_ref = format!("refs/heads/{short_name}");
538 let dst = default_push_destination(repo, config, remote, &short_name, &src_ref)?;
539 let spec = push_spec(Some(oid), Some(src_ref.clone()), dst, false, false);
540 Ok(ResolvedPush {
541 remote: remote.to_owned(),
542 specs: vec![spec],
543 branch_label: Some(short_name),
544 kind: PushPlanKind::Branch,
545 })
546}
547
548fn resolve_matching_default_push(
550 repo: &Repository,
551 remote: &str,
552 ctx: &PushPlanContext,
553) -> PlanResult<ResolvedPush> {
554 let heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
555 let mut specs = Vec::new();
556 for (name, oid) in heads {
557 if ctx.remote_ref_names.contains(&name) {
558 specs.push(push_spec(
559 Some(oid),
560 Some(name.clone()),
561 name.clone(),
562 false,
563 false,
564 ));
565 }
566 }
567 let branch_label = branch_label_from_specs(repo, &specs);
568 let kind = if specs.len() == 1 {
569 PushPlanKind::Branch
570 } else {
571 PushPlanKind::Refspecs
572 };
573 Ok(ResolvedPush {
574 remote: remote.to_owned(),
575 specs,
576 branch_label,
577 kind,
578 })
579}
580
581fn current_branch_oid(repo: &Repository) -> PlanResult<(String, ObjectId)> {
582 match resolve_head(repo.git_dir()).map_err(|e| PushPlanError::Message(e.to_string()))? {
583 HeadState::Branch {
584 short_name,
585 oid: Some(oid),
586 ..
587 } => Ok((short_name, oid)),
588 HeadState::Branch { .. } => Err(PushPlanError::NoCommits),
589 HeadState::Detached { .. } => Err(PushPlanError::DetachedHeadNoRefspec),
590 HeadState::Invalid => Err(PushPlanError::Message("HEAD is in an unknown state".into())),
591 }
592}
593
594pub fn configured_remote_for_push(
602 repo: &Repository,
603 config: &ConfigSet,
604 remote_override: Option<&str>,
605) -> PlanResult<String> {
606 if let Some(remote) = remote_override.filter(|r| !r.trim().is_empty()) {
607 return Ok(remote.to_owned());
608 }
609 if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
610 if let Some(remote) = config
611 .get(&format!("branch.{short_name}.pushremote"))
612 .or_else(|| config.get(&format!("branch.{short_name}.pushRemote")))
613 .filter(|r| !r.trim().is_empty())
614 {
615 return Ok(remote);
616 }
617 }
618 if let Some(remote) = config
619 .get("remote.pushDefault")
620 .filter(|r| !r.trim().is_empty())
621 {
622 return Ok(remote);
623 }
624 if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
625 if let Some(remote) = config
626 .get(&format!("branch.{short_name}.remote"))
627 .filter(|r| !r.trim().is_empty() && r != ".")
628 {
629 return Ok(remote);
630 }
631 }
632 if !has_remote_section(config, DEFAULT_REMOTE) {
633 return Err(PushPlanError::NoRemote);
634 }
635 Ok(DEFAULT_REMOTE.to_owned())
636}
637
638#[derive(Debug, Clone, Default, PartialEq, Eq)]
640pub struct PushInvocation {
641 pub remote: Option<String>,
643 pub refspecs: Vec<String>,
645}
646
647pub fn resolve_push_invocation(
660 config: &ConfigSet,
661 remote_flag: Option<&str>,
662 positionals: &[String],
663) -> PlanResult<PushInvocation> {
664 if let Some(remote) = remote_flag {
665 return Ok(PushInvocation {
666 remote: Some(remote.to_owned()),
667 refspecs: positionals.to_vec(),
668 });
669 }
670 let Some((first, rest)) = positionals.split_first() else {
671 return Ok(PushInvocation::default());
672 };
673 if has_remote_url(config, first) || looks_like_literal_remote(first) {
674 return Ok(PushInvocation {
675 remote: Some(first.clone()),
676 refspecs: rest.to_vec(),
677 });
678 }
679 if !rest.is_empty() {
680 return Err(PushPlanError::UnknownRemote(first.clone()));
681 }
682 Ok(PushInvocation {
683 remote: None,
684 refspecs: positionals.to_vec(),
685 })
686}
687
688#[must_use]
696pub fn expand_repository_shorthand(
697 config: &ConfigSet,
698 positionals: &[String],
699 host: &str,
700) -> Option<String> {
701 let [first, _, ..] = positionals else {
702 return None;
703 };
704 if has_remote_url(config, first) || looks_like_literal_remote(first) {
705 return None;
706 }
707 let is_segment = |s: &str| {
708 !s.is_empty()
709 && !s.starts_with('.')
710 && s.chars()
711 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
712 };
713 let (owner, project) = first.split_once('/')?;
714 (is_segment(owner) && is_segment(project))
715 .then(|| format!("{}/{owner}/{project}.git", host.trim_end_matches('/')))
716}
717
718pub fn open_push_destination(config: &ConfigSet, name: &str) -> Result<Remote, RemoteError> {
727 if looks_like_literal_remote(name) {
728 Remote::from_url(name)
729 } else {
730 Remote::from_config(config, name)
731 }
732}
733
734impl PushPlanContext {
735 #[must_use]
737 pub fn from_advertised_refs(refs: impl IntoIterator<Item = RemoteRef>) -> Self {
738 let mut ctx = Self::default();
739 let mut tips = Vec::new();
740 for remote_ref in refs {
741 if remote_ref.peeled_tag().is_some() {
742 continue;
743 }
744 if remote_ref.name.starts_with("refs/heads/") {
745 ctx.remote_branch_tips.insert(remote_ref.oid);
746 }
747 tips.push(remote_ref.oid);
748 ctx.remote_ref_names.insert(remote_ref.name);
749 }
750 ctx.remote_tips = Some(tips);
751 ctx
752 }
753}
754
755pub fn load_push_plan_context(
770 repo: &Repository,
771 config: &ConfigSet,
772 remote_override: Option<&str>,
773 http_factory: Option<&dyn HttpClientFactory>,
774) -> PlanResult<PushPlanContext> {
775 let remote = configured_remote_for_push(repo, config, remote_override)?;
776 let Ok(handle) = open_push_destination(config, &remote) else {
777 return Ok(PushPlanContext::default());
778 };
779 let probe = ProbeHttpClientFactory::new(http_factory, repo);
780 let Ok(advertised) = handle.list_push_refs(repo, Some(&probe)) else {
781 return Ok(PushPlanContext::default());
782 };
783 Ok(PushPlanContext::from_advertised_refs(advertised))
784}
785
786fn looks_like_literal_remote(name: &str) -> bool {
787 if name.contains("://") || name.ends_with(".git") || name.starts_with('/') {
788 return true;
789 }
790 if name == "." || name == ".." || name.starts_with("./") || name.starts_with("../") {
791 return true;
792 }
793 is_git_directory(Path::new(name))
794}
795
796fn has_remote_url(config: &ConfigSet, name: &str) -> bool {
797 config
798 .get(&format!("remote.{name}.url"))
799 .is_some_and(|u| !u.trim().is_empty())
800}
801
802fn validate_remote(config: &ConfigSet, name: &str) -> PlanResult<()> {
803 if has_remote_url(config, name) {
804 Ok(())
805 } else if name == DEFAULT_REMOTE || looks_like_literal_remote(name) {
806 Ok(())
808 } else {
809 Err(PushPlanError::UnknownRemote(name.to_owned()))
810 }
811}
812
813fn remote_push_refspec_strings(config: &ConfigSet, remote: &str) -> Vec<String> {
814 config
815 .get_all(&format!("remote.{remote}.push"))
816 .into_iter()
817 .filter(|s| !s.trim().is_empty())
818 .collect()
819}
820
821fn default_push_destination(
822 repo: &Repository,
823 config: &ConfigSet,
824 remote: &str,
825 branch_short: &str,
826 _src_ref: &str,
827) -> PlanResult<String> {
828 let push_default = config
829 .get("push.default")
830 .unwrap_or_else(|| "simple".to_owned());
831
832 if push_default == "nothing" {
833 return Err(PushPlanError::PushDefaultNothing);
834 }
835
836 if push_default.as_str() == "upstream" {
837 let merge = config
838 .get(&format!("branch.{branch_short}.merge"))
839 .filter(|m| m.starts_with("refs/"));
840 return merge.ok_or_else(|| {
841 PushPlanError::Message(format!(
842 "branch '{branch_short}' has no upstream for push.default upstream"
843 ))
844 });
845 }
846
847 if push_default.as_str() == "simple" {
850 let branch_remote = config.get(&format!("branch.{branch_short}.remote"));
851 if branch_remote.as_deref() == Some(remote) {
852 if let Some(merge) = config
853 .get(&format!("branch.{branch_short}.merge"))
854 .filter(|m| m.starts_with("refs/"))
855 {
856 let _merge_branch = merge.strip_prefix("refs/heads/").unwrap_or(merge.as_str());
857 let same_name_dst = format!("refs/heads/{branch_short}");
858 if merge != same_name_dst {
859 return Ok(merge);
861 }
862 return Ok(same_name_dst);
863 }
864 }
865 }
866
867 let _ = resolve_push_full_ref_for_branch(repo, branch_short);
868 Ok(format!("refs/heads/{branch_short}"))
869}
870
871fn resolve_cli_refspec(
872 repo: &Repository,
873 config: &ConfigSet,
874 remote: &str,
875 spec: &str,
876 ctx: &PushPlanContext,
877 local: &LocalPushRefs,
878) -> PlanResult<Vec<PushRefSpec>> {
879 if !spec.contains(':') {
880 return resolve_shorthand_refspec(repo, config, remote, spec, ctx, local);
881 }
882 let item = parse_push_refspec(spec).map_err(|e| PushPlanError::InvalidRefspec {
883 spec: spec.to_owned(),
884 reason: e.to_string(),
885 })?;
886 resolve_parsed_refspec(repo, &item, ctx, local)
887}
888
889fn resolve_shorthand_refspec(
890 repo: &Repository,
891 config: &ConfigSet,
892 remote: &str,
893 spec: &str,
894 ctx: &PushPlanContext,
895 local: &LocalPushRefs,
896) -> PlanResult<Vec<PushRefSpec>> {
897 let count = local.count_refname_match(spec);
898 if count == 1 {
899 let Some(matched) = local.refname_match(spec) else {
900 return Err(PushPlanError::SrcRefspecNoMatch {
901 spec: spec.to_owned(),
902 });
903 };
904 if let Some(mapped) = map_through_remote_push_refspec(config, remote, &matched.0) {
905 let item = parse_push_refspec(&mapped).map_err(|e| PushPlanError::InvalidRefspec {
906 spec: mapped.clone(),
907 reason: e.to_string(),
908 })?;
909 return resolve_parsed_refspec(repo, &item, ctx, local);
910 }
911 let item = RefspecItem {
912 force: false,
913 src: Some(matched.0.clone()),
914 dst: Some(matched.0.clone()),
915 ..Default::default()
916 };
917 return push_spec_from_parsed_item(repo, &item, ctx, matched);
918 }
919 if count == 0 {
920 let item = parse_push_refspec(spec).map_err(|e| PushPlanError::InvalidRefspec {
922 spec: spec.to_owned(),
923 reason: e.to_string(),
924 })?;
925 return resolve_parsed_refspec(repo, &item, ctx, local);
926 }
927 Err(PushPlanError::SrcRefspecAmbiguous {
928 spec: spec.to_owned(),
929 })
930}
931
932fn map_through_remote_push_refspec(
933 config: &ConfigSet,
934 remote: &str,
935 local_full_ref: &str,
936) -> Option<String> {
937 for spec in remote_push_refspec_strings(config, remote) {
938 let Ok(item) = parse_push_refspec(&spec) else {
939 continue;
940 };
941 if item.negative || item.matching {
942 continue;
943 }
944 if let Some(dst) = refspec_map_src(&item, local_full_ref) {
945 let force = if item.force { "+" } else { "" };
946 return Some(format!("{force}{local_full_ref}:{dst}"));
947 }
948 }
949 None
950}
951
952fn expand_configured_push_refspec(
953 repo: &Repository,
954 spec: String,
955 ctx: &PushPlanContext,
956 local: &LocalPushRefs,
957) -> PlanResult<Vec<PushRefSpec>> {
958 let item = parse_push_refspec(&spec).map_err(|e| PushPlanError::InvalidRefspec {
959 spec: spec.clone(),
960 reason: e.to_string(),
961 })?;
962 if item.matching {
963 let mut out = Vec::new();
964 for (name, oid) in local.heads() {
965 if !ctx.remote_ref_names.contains(name) {
966 continue;
967 }
968 out.push(push_spec(
969 Some(*oid),
970 Some(name.clone()),
971 name.clone(),
972 item.force,
973 false,
974 ));
975 }
976 return Ok(out);
977 }
978 if item.pattern {
979 let prefix = item.src.as_deref().unwrap_or("");
980 let refs = if prefix.starts_with("refs/tags/") {
981 local.tags()
982 } else {
983 local.heads()
984 };
985 let mut out = Vec::new();
986 for (name, oid) in refs {
987 if let Some(dst) = refspec_map_src(&item, name) {
988 out.push(push_spec(
989 Some(*oid),
990 Some(name.clone()),
991 dst,
992 item.force,
993 false,
994 ));
995 }
996 }
997 return Ok(out);
998 }
999 resolve_parsed_refspec(repo, &item, ctx, local)
1000}
1001
1002fn resolve_parsed_refspec(
1003 repo: &Repository,
1004 item: &RefspecItem,
1005 ctx: &PushPlanContext,
1006 local: &LocalPushRefs,
1007) -> PlanResult<Vec<PushRefSpec>> {
1008 if item.negative {
1009 return Ok(Vec::new());
1010 }
1011 if item.matching {
1012 return expand_configured_push_refspec(
1013 repo,
1014 if item.force {
1015 "+:".to_owned()
1016 } else {
1017 ":".to_owned()
1018 },
1019 ctx,
1020 local,
1021 );
1022 }
1023
1024 let src_empty = item.src.as_deref().is_some_and(|s| s.is_empty());
1025 let src_name = item.src.as_deref().unwrap_or("");
1026
1027 if src_empty || (src_name.is_empty() && item.dst.is_some()) {
1028 let dst = item
1029 .dst
1030 .as_ref()
1031 .ok_or_else(|| PushPlanError::InvalidRefspec {
1032 spec: String::new(),
1033 reason: "delete refspec missing destination".into(),
1034 })?;
1035 let resolved_dst = resolve_dst_name(repo, dst, None, ctx)?;
1036 return Ok(vec![push_spec(None, None, resolved_dst, item.force, true)]);
1037 }
1038
1039 let src_oid = if item.exact_sha1 {
1040 ObjectId::from_hex(src_name).map_err(|_| PushPlanError::UnknownRevision {
1041 spec: src_name.to_owned(),
1042 })?
1043 } else if src_name.is_empty() {
1044 resolve_src_ref(repo, "HEAD")?
1045 } else {
1046 match resolve_src_ref(repo, src_name) {
1047 Ok(oid) => oid,
1048 Err(_) => match local.count_refname_match(src_name) {
1049 0 => {
1050 return Err(PushPlanError::SrcRefspecNoMatch {
1051 spec: src_name.to_owned(),
1052 });
1053 }
1054 1 => {
1055 let Some((name, oid)) = local.refname_match(src_name) else {
1056 return Err(PushPlanError::SrcRefspecNoMatch {
1057 spec: src_name.to_owned(),
1058 });
1059 };
1060 let dst = resolve_dst_for_src(repo, item, &name, ctx)?;
1061 return Ok(vec![push_spec(
1062 Some(oid),
1063 Some(name.clone()),
1064 dst,
1065 item.force,
1066 false,
1067 )]);
1068 }
1069 _ => {
1070 return Err(PushPlanError::SrcRefspecAmbiguous {
1071 spec: src_name.to_owned(),
1072 });
1073 }
1074 },
1075 }
1076 };
1077
1078 let known_src = if item.exact_sha1
1079 || src_name.is_empty()
1080 || src_name.eq_ignore_ascii_case("HEAD")
1081 || src_name == "@"
1082 {
1083 None
1084 } else {
1085 local.refname_match(src_name)
1086 };
1087 let src_full = if item.exact_sha1 {
1088 None
1089 } else if let Some((name, _)) = &known_src {
1090 Some(name.clone())
1091 } else {
1092 Some(full_src_ref_name(repo, src_name, local)?)
1093 };
1094 let src_label = src_full.clone();
1095 let dst = resolve_dst_for_src(repo, item, src_label.as_deref().unwrap_or(src_name), ctx)?;
1096 let mut spec = push_spec(Some(src_oid), src_label, dst, item.force, false);
1097 if matches!(src_name, "HEAD" | "@") {
1098 spec.hook_src_ref = Some("HEAD".to_owned());
1099 }
1100 Ok(vec![spec])
1101}
1102
1103fn push_spec_from_parsed_item(
1104 repo: &Repository,
1105 item: &RefspecItem,
1106 ctx: &PushPlanContext,
1107 known_src: (String, ObjectId),
1108) -> PlanResult<Vec<PushRefSpec>> {
1109 let src_name = item.src.as_deref().unwrap_or("");
1110 let src_oid = known_src.1;
1111
1112 let src_full = if item.exact_sha1 {
1113 None
1114 } else {
1115 Some(known_src.0.clone())
1116 };
1117 let src_label = src_full.clone();
1118 let dst = resolve_dst_for_src(repo, item, src_label.as_deref().unwrap_or(src_name), ctx)?;
1119 let mut spec = push_spec(Some(src_oid), src_label, dst, item.force, false);
1120 if matches!(src_name, "HEAD" | "@") {
1121 spec.hook_src_ref = Some("HEAD".to_owned());
1122 }
1123 Ok(vec![spec])
1124}
1125
1126fn resolve_dst_for_src(
1127 repo: &Repository,
1128 item: &RefspecItem,
1129 src_full: &str,
1130 ctx: &PushPlanContext,
1131) -> PlanResult<String> {
1132 if let Some(dst) = &item.dst {
1133 if dst.is_empty() {
1134 return Err(PushPlanError::InvalidRefspec {
1135 spec: String::new(),
1136 reason: "empty destination".into(),
1137 });
1138 }
1139 return resolve_dst_name(repo, dst, Some(src_full), ctx);
1140 }
1141 if src_full.starts_with("refs/heads/") {
1143 return Ok(src_full.to_owned());
1144 }
1145 if src_full.starts_with("refs/tags/") {
1146 return Ok(src_full.to_owned());
1147 }
1148 Ok(src_full.to_owned())
1149}
1150
1151fn resolve_dst_name(
1152 _repo: &Repository,
1153 dst: &str,
1154 src_full: Option<&str>,
1155 ctx: &PushPlanContext,
1156) -> PlanResult<String> {
1157 if dst.starts_with("refs/") {
1158 return Ok(dst.to_owned());
1159 }
1160 let remote_list = remote_ref_list(ctx);
1161 match count_refname_match(dst, &remote_list) {
1162 0 => {}
1163 1 => {
1164 let Some((name, _)) = refname_match(dst, &remote_list) else {
1165 return Err(PushPlanError::InvalidRefspec {
1166 spec: dst.to_owned(),
1167 reason: "remote ref match failed".into(),
1168 });
1169 };
1170 return Ok(name);
1171 }
1172 _ => {
1173 return Err(PushPlanError::DstRefspecAmbiguous {
1174 spec: dst.to_owned(),
1175 });
1176 }
1177 }
1178 if let Some(src) = src_full {
1179 if let Some(guessed) = guess_qualified_dst(src, dst) {
1180 return Ok(guessed);
1181 }
1182 }
1183 Ok(format!("refs/heads/{dst}"))
1184}
1185
1186fn remote_ref_list(ctx: &PushPlanContext) -> Vec<(String, ObjectId)> {
1187 ctx.remote_ref_names
1188 .iter()
1189 .filter_map(|n| {
1190 ObjectId::from_hex("0".repeat(40).as_str())
1192 .ok()
1193 .map(|z| (n.clone(), z))
1194 })
1195 .collect()
1196}
1197
1198fn guess_qualified_dst(src_full: &str, short_dst: &str) -> Option<String> {
1199 if src_full.starts_with("refs/heads/") {
1200 Some(format!("refs/heads/{short_dst}"))
1201 } else if src_full.starts_with("refs/tags/") {
1202 Some(format!("refs/tags/{short_dst}"))
1203 } else {
1204 None
1205 }
1206}
1207
1208fn apply_force_mode(
1210 repo: &Repository,
1211 config: &ConfigSet,
1212 remote: &str,
1213 specs: &mut [PushRefSpec],
1214 mode: &ForceMode,
1215) -> PlanResult<()> {
1216 match mode {
1217 ForceMode::None => {}
1218 ForceMode::Force => {
1219 for spec in specs.iter_mut() {
1220 spec.force = true;
1221 spec.expected_old = None;
1222 spec.expect_absent = false;
1223 }
1224 }
1225 ForceMode::Lease(LeaseExpectation::Tracking) => {
1226 for spec in specs.iter_mut() {
1227 apply_tracking_lease(repo, config, remote, spec);
1228 }
1229 }
1230 ForceMode::Lease(LeaseExpectation::Value(expected)) => {
1231 if specs.len() != 1 {
1232 return Err(PushPlanError::LeaseValueRequiresSingleRef);
1233 }
1234 for spec in specs.iter_mut() {
1235 spec.force = true;
1236 spec.expected_old = Some(*expected);
1237 spec.expect_absent = false;
1238 }
1239 }
1240 }
1241 Ok(())
1242}
1243
1244fn apply_tracking_lease(
1245 repo: &Repository,
1246 config: &ConfigSet,
1247 remote: &str,
1248 spec: &mut PushRefSpec,
1249) {
1250 spec.force = true;
1251 spec.expected_old = None;
1252 spec.expect_absent = false;
1253 if let Some(tracking) = remote_tracking_ref_for_remote_ref(config, remote, &spec.dst) {
1254 match refs::resolve_ref(repo.git_dir(), &tracking) {
1255 Ok(oid) => spec.expected_old = Some(oid),
1256 Err(_) => spec.expect_absent = true,
1257 }
1258 } else {
1259 spec.expect_absent = true;
1260 }
1261}
1262
1263fn push_spec(
1264 src: Option<ObjectId>,
1265 src_ref: Option<String>,
1266 dst: String,
1267 force: bool,
1268 delete: bool,
1269) -> PushRefSpec {
1270 PushRefSpec {
1271 src,
1272 src_ref,
1273 dst,
1274 force,
1275 delete,
1276 ..Default::default()
1277 }
1278}
1279
1280fn resolve_src_ref(repo: &Repository, spec: &str) -> PlanResult<ObjectId> {
1281 resolve_revision_for_verify(repo, spec).map_err(|_| PushPlanError::UnknownRevision {
1282 spec: spec.to_owned(),
1283 })
1284}
1285
1286fn full_src_ref_name(repo: &Repository, spec: &str, local: &LocalPushRefs) -> PlanResult<String> {
1287 if spec.eq_ignore_ascii_case("HEAD") {
1288 if let Ok(HeadState::Branch { short_name, .. }) = resolve_head(repo.git_dir()) {
1289 return Ok(format!("refs/heads/{short_name}"));
1290 }
1291 }
1292 if spec.starts_with("refs/") {
1293 return Ok(spec.to_owned());
1294 }
1295 if let Some((name, _)) = local.refname_match(spec) {
1296 return Ok(name);
1297 }
1298 Ok(spec.to_owned())
1299}
1300
1301struct LocalPushRefs {
1303 all: Vec<(String, ObjectId)>,
1304 by_full: HashMap<String, ObjectId>,
1305 by_short: HashMap<String, Vec<(String, ObjectId)>>,
1306 heads: Vec<(String, ObjectId)>,
1307 tags: Vec<(String, ObjectId)>,
1308}
1309
1310impl LocalPushRefs {
1311 fn load(repo: &Repository) -> PlanResult<Self> {
1312 let heads = list_local_refs(repo.git_dir(), "refs/heads/")?;
1313 let tags = list_local_refs(repo.git_dir(), "refs/tags/")?;
1314 let mut all = Vec::with_capacity(heads.len() + tags.len());
1315 let mut by_full = HashMap::with_capacity(heads.len() + tags.len());
1316 let mut by_short: HashMap<String, Vec<(String, ObjectId)>> = HashMap::new();
1317 for entry in heads.iter().chain(tags.iter()) {
1318 all.push(entry.clone());
1319 by_full.insert(entry.0.clone(), entry.1);
1320 if let Some(short) = entry
1321 .0
1322 .strip_prefix("refs/heads/")
1323 .or_else(|| entry.0.strip_prefix("refs/tags/"))
1324 {
1325 by_short
1326 .entry(short.to_owned())
1327 .or_default()
1328 .push(entry.clone());
1329 }
1330 }
1331 Ok(Self {
1332 all,
1333 by_full,
1334 by_short,
1335 heads,
1336 tags,
1337 })
1338 }
1339
1340 fn heads(&self) -> &[(String, ObjectId)] {
1341 &self.heads
1342 }
1343
1344 fn tags(&self) -> &[(String, ObjectId)] {
1345 &self.tags
1346 }
1347
1348 fn count_refname_match(&self, pattern: &str) -> usize {
1349 if (pattern.starts_with("refs/heads/") || pattern.starts_with("refs/tags/"))
1350 && self.by_full.contains_key(pattern)
1351 {
1352 return 1;
1353 }
1354 if let Some(entries) = self.by_short.get(pattern) {
1355 return count_refname_match(pattern, entries);
1356 }
1357 count_refname_match(pattern, &self.all)
1358 }
1359
1360 fn refname_match(&self, pattern: &str) -> Option<(String, ObjectId)> {
1361 if pattern.starts_with("refs/heads/") || pattern.starts_with("refs/tags/") {
1362 if let Some(oid) = self.by_full.get(pattern) {
1363 return Some((pattern.to_owned(), *oid));
1364 }
1365 }
1366 if let Some(entries) = self.by_short.get(pattern) {
1367 return refname_match(pattern, entries);
1368 }
1369 refname_match(pattern, &self.all)
1370 }
1371}
1372
1373fn list_local_refs(git_dir: &Path, prefix: &str) -> PlanResult<Vec<(String, ObjectId)>> {
1374 refs::list_refs(git_dir, prefix).map_err(|e| PushPlanError::Message(e.to_string()))
1375}
1376
1377fn count_refname_match(pattern: &str, refs: &[(String, ObjectId)]) -> usize {
1379 let mut strong = 0usize;
1380 let mut weak = 0usize;
1381 let patlen = pattern.len();
1382 for (name, _) in refs {
1383 if !refname_match_one(pattern, name) {
1384 continue;
1385 }
1386 let namelen = name.len();
1387 if namelen != patlen
1388 && patlen != namelen.saturating_sub(5)
1389 && !name.starts_with("refs/heads/")
1390 && !name.starts_with("refs/tags/")
1391 {
1392 weak += 1;
1393 } else {
1394 strong += 1;
1395 }
1396 }
1397 if strong == 0 {
1398 weak
1399 } else {
1400 strong
1401 }
1402}
1403
1404fn refname_match(pattern: &str, refs: &[(String, ObjectId)]) -> Option<(String, ObjectId)> {
1405 let mut strong: Option<(String, ObjectId)> = None;
1406 let mut weak: Option<(String, ObjectId)> = None;
1407 let patlen = pattern.len();
1408 for (name, oid) in refs {
1409 if !refname_match_one(pattern, name) {
1410 continue;
1411 }
1412 let namelen = name.len();
1413 if namelen != patlen
1414 && patlen != namelen.saturating_sub(5)
1415 && !name.starts_with("refs/heads/")
1416 && !name.starts_with("refs/tags/")
1417 {
1418 weak = Some((name.clone(), *oid));
1419 } else {
1420 strong = Some((name.clone(), *oid));
1421 }
1422 }
1423 strong.or(weak)
1424}
1425
1426fn refname_match_one(pattern: &str, refname: &str) -> bool {
1427 if pattern == refname {
1428 return true;
1429 }
1430 if refname.ends_with(pattern) {
1431 if refname == format!("refs/heads/{pattern}") || refname == format!("refs/tags/{pattern}") {
1433 return true;
1434 }
1435 if pattern.len() + 5 == refname.len() && refname.starts_with("refs/heads/") {
1436 return refname.strip_prefix("refs/heads/") == Some(pattern);
1437 }
1438 if pattern.len() + 5 == refname.len() && refname.starts_with("refs/tags/") {
1439 return refname.strip_prefix("refs/tags/") == Some(pattern);
1440 }
1441 }
1442 false
1443}
1444
1445fn refspec_map_src(item: &RefspecItem, src_ref: &str) -> Option<String> {
1446 let src_pat = item.src.as_deref()?;
1447 let dst_pat = item.dst.as_deref()?;
1448 match_refname_with_pattern(src_pat, src_ref, dst_pat)
1449}
1450
1451fn push_spec_excluded_by_negative(item: &RefspecItem, spec: &PushRefSpec) -> bool {
1453 if !item.negative {
1454 return false;
1455 }
1456 let Some(pat) = item.src.as_deref() else {
1457 return false;
1458 };
1459 let local = spec.src_ref.as_deref().or_else(|| {
1460 spec.dst
1461 .starts_with("refs/heads/")
1462 .then_some(spec.dst.as_str())
1463 });
1464 let Some(local) = local else {
1465 return false;
1466 };
1467 if item.pattern {
1468 return match_refname_with_pattern(pat, local, pat).is_some();
1469 }
1470 pat == local || refname_match_one(pat, local)
1471}
1472
1473fn match_refname_with_pattern(pattern: &str, refname: &str, replacement: &str) -> Option<String> {
1474 match (pattern.find('*'), replacement.find('*')) {
1475 (Some(pstar), Some(vstar)) => {
1476 let kprefix = &pattern[..pstar];
1477 let ksuffix = &pattern[pstar + 1..];
1478 if !refname.starts_with(kprefix) || !refname.ends_with(ksuffix) {
1479 return None;
1480 }
1481 if refname.len() < kprefix.len() + ksuffix.len() {
1482 return None;
1483 }
1484 let middle = &refname[kprefix.len()..refname.len() - ksuffix.len()];
1485 Some(format!(
1486 "{}{}{}",
1487 &replacement[..vstar],
1488 middle,
1489 &replacement[vstar + 1..]
1490 ))
1491 }
1492 (None, None) if pattern == refname => Some(replacement.to_owned()),
1493 _ => None,
1494 }
1495}
1496
1497fn delete_branch_label(specs: &[PushRefSpec]) -> Option<String> {
1498 if specs.len() != 1 {
1499 return None;
1500 }
1501 specs[0].dst.strip_prefix("refs/heads/").map(str::to_owned)
1502}
1503
1504fn branch_label_from_specs(_repo: &Repository, specs: &[PushRefSpec]) -> Option<String> {
1505 if specs.len() != 1 || specs[0].delete {
1506 return None;
1507 }
1508 let dst = &specs[0].dst;
1509 dst.strip_prefix("refs/heads/")
1510 .or_else(|| dst.strip_prefix("refs/tags/"))
1511 .map(str::to_owned)
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516 use super::*;
1517 use crate::config::environment::Environment;
1518 use crate::repo::{InitOptions, Repository};
1519
1520 fn init_repo() -> (tempfile::TempDir, Repository) {
1521 let tmp = tempfile::tempdir().expect("tempdir");
1522 let repo = Repository::init(tmp.path(), &InitOptions::default()).expect("init");
1523 (tmp, repo)
1524 }
1525
1526 #[test]
1527 fn push_remote_precedence_pushremote_over_remote() {
1528 let (_tmp, repo) = init_repo();
1529 let git_dir = repo.git_dir();
1530 std::fs::write(
1531 git_dir.join("config"),
1532 r#"[remote "origin"]
1533 url = /tmp/o.git
1534[remote "upstream"]
1535 url = /tmp/u.git
1536[branch "main"]
1537 remote = origin
1538 pushRemote = upstream
1539"#,
1540 )
1541 .expect("write config");
1542 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
1543 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1544 let remote = configured_remote_for_push(&repo, &config, None).expect("remote");
1545 assert_eq!(remote, "upstream");
1546 }
1547
1548 #[test]
1549 fn map_through_skips_malformed_remote_push_refspec() {
1550 let (_tmp, repo) = init_repo();
1551 let git_dir = repo.git_dir();
1552 std::fs::write(
1553 git_dir.join("config"),
1554 r#"[remote "origin"]
1555 url = /tmp/o.git
1556 push = not a valid refspec at all
1557 push = refs/heads/*:refs/remotes/origin/*
1558"#,
1559 )
1560 .expect("write config");
1561 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
1562 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1563 let mapped = map_through_remote_push_refspec(&config, "origin", "refs/heads/main");
1564 assert_eq!(
1565 mapped.as_deref(),
1566 Some("refs/heads/main:refs/remotes/origin/main")
1567 );
1568 }
1569
1570 #[test]
1571 fn force_with_lease_tracking_sets_expected_old() {
1572 let (_tmp, repo) = init_repo();
1573 let git_dir = repo.git_dir();
1574 std::fs::write(
1575 git_dir.join("config"),
1576 r#"[remote "origin"]
1577 url = /tmp/o.git
1578 fetch = +refs/heads/*:refs/remotes/origin/*
1579[branch "main"]
1580 remote = origin
1581 merge = refs/heads/main
1582"#,
1583 )
1584 .expect("config");
1585 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
1586 let oid = ObjectId::from_hex("a".repeat(40).as_str()).expect("oid");
1587 std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads dir");
1588 std::fs::write(
1589 git_dir.join("refs/heads/main"),
1590 format!("{}\n", oid.to_hex()),
1591 )
1592 .expect("main");
1593 std::fs::create_dir_all(git_dir.join("refs/remotes/origin")).expect("rt dir");
1594 std::fs::write(
1595 git_dir.join("refs/remotes/origin/main"),
1596 format!("{}\n", oid.to_hex()),
1597 )
1598 .expect("tracking");
1599 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1600 let ctx = PushPlanContext::default();
1601 let resolved = resolve_push(
1602 &repo,
1603 &config,
1604 &PushRequest {
1605 force: ForceMode::Lease(LeaseExpectation::Tracking),
1606 ..Default::default()
1607 },
1608 &ctx,
1609 )
1610 .expect("resolve");
1611 assert_eq!(resolved.specs.len(), 1);
1612 assert_eq!(resolved.specs[0].expected_old, Some(oid));
1613 assert!(resolved.specs[0].force);
1614 }
1615
1616 #[test]
1617 fn negative_push_refspec_filters_planned_spec() {
1618 let item = parse_push_refspec("^refs/heads/private").expect("parse");
1619 let kept = push_spec(
1620 Some(ObjectId::from_hex("0".repeat(40).as_str()).expect("oid")),
1621 Some("refs/heads/main".into()),
1622 "refs/heads/main".into(),
1623 false,
1624 false,
1625 );
1626 let dropped = push_spec(
1627 Some(ObjectId::from_hex("1".repeat(40).as_str()).expect("oid")),
1628 Some("refs/heads/private".into()),
1629 "refs/heads/private".into(),
1630 false,
1631 false,
1632 );
1633 assert!(!push_spec_excluded_by_negative(&item, &kept));
1634 assert!(push_spec_excluded_by_negative(&item, &dropped));
1635 }
1636
1637 fn config_from(text: &str) -> ConfigSet {
1638 let file = crate::config::ConfigFile::parse(
1639 Path::new("config"),
1640 text,
1641 crate::config::ConfigScope::Local,
1642 )
1643 .expect("parse config");
1644 let mut config = ConfigSet::new();
1645 config.merge(&file);
1646 config
1647 }
1648
1649 fn strings(items: &[&str]) -> Vec<String> {
1650 items.iter().map(|s| (*s).to_owned()).collect()
1651 }
1652
1653 #[test]
1654 fn push_invocation_remote_flag_keeps_every_positional_as_refspec() {
1655 let config = ConfigSet::new();
1656 let invocation =
1657 resolve_push_invocation(&config, Some("upstream"), &strings(&["origin", "main"]))
1658 .expect("invocation");
1659 assert_eq!(
1660 invocation,
1661 PushInvocation {
1662 remote: Some("upstream".into()),
1663 refspecs: strings(&["origin", "main"]),
1664 }
1665 );
1666 }
1667
1668 #[test]
1669 fn push_invocation_without_positionals_uses_default_remote() {
1670 let invocation = resolve_push_invocation(&ConfigSet::new(), None, &[]).expect("invocation");
1671 assert_eq!(invocation, PushInvocation::default());
1672 }
1673
1674 #[test]
1675 fn push_invocation_first_positional_is_configured_remote() {
1676 let config = config_from("[remote \"upstream\"]\n\turl = /tmp/u.git\n");
1677 for positionals in [strings(&["upstream"]), strings(&["upstream", "a", "b:c"])] {
1678 let invocation =
1679 resolve_push_invocation(&config, None, &positionals).expect("invocation");
1680 assert_eq!(invocation.remote.as_deref(), Some("upstream"));
1681 assert_eq!(invocation.refspecs, positionals[1..]);
1682 }
1683 }
1684
1685 #[test]
1686 fn push_invocation_first_positional_is_literal_url_or_path() {
1687 let config = ConfigSet::new();
1688 for remote in [
1689 "https://example.com/r",
1690 "../sibling.git",
1691 "/srv/repo",
1692 "../srv",
1693 "./srv",
1694 "./../srv",
1695 "../deep/nb3",
1696 ".",
1697 "..",
1698 ] {
1699 let invocation = resolve_push_invocation(&config, None, &strings(&[remote, "main"]))
1700 .expect("invocation");
1701 assert_eq!(invocation.remote.as_deref(), Some(remote));
1702 assert_eq!(invocation.refspecs, strings(&["main"]));
1703 }
1704 }
1705
1706 #[test]
1707 fn push_invocation_single_unknown_positional_is_a_refspec() {
1708 let config = config_from("[remote \"blank\"]\n\turl = \n");
1709 for token in ["main", "HEAD:refs/heads/x", "blank"] {
1710 let invocation =
1711 resolve_push_invocation(&config, None, &strings(&[token])).expect("invocation");
1712 assert_eq!(invocation.remote, None, "{token}");
1713 assert_eq!(invocation.refspecs, strings(&[token]));
1714 }
1715 }
1716
1717 #[test]
1718 fn push_without_any_remote_is_no_remote() {
1719 let (_tmp, repo) = init_repo();
1720 assert_eq!(
1721 configured_remote_for_push(&repo, &ConfigSet::new(), None),
1722 Err(PushPlanError::NoRemote)
1723 );
1724 assert_eq!(PushPlanError::NoRemote.to_string(), "no remote to push to");
1725 assert_eq!(
1726 load_push_plan_context(&repo, &ConfigSet::new(), None, None).map(|_| ()),
1727 Err(PushPlanError::NoRemote)
1728 );
1729 let named = configured_remote_for_push(&repo, &ConfigSet::new(), Some("upstream"));
1730 assert_eq!(named.as_deref(), Ok("upstream"));
1731 let url_less_origin = config_from("[remote \"origin\"]\n\tpushurl = /tmp/o.git\n");
1732 assert_eq!(
1733 configured_remote_for_push(&repo, &url_less_origin, None).as_deref(),
1734 Ok("origin")
1735 );
1736 }
1737
1738 #[test]
1739 fn push_invocation_unknown_remote_before_refspecs_is_rejected() {
1740 let err = resolve_push_invocation(&ConfigSet::new(), None, &strings(&["nosuch", "main"]))
1741 .expect_err("unknown remote");
1742 assert_eq!(err, PushPlanError::UnknownRemote("nosuch".into()));
1743 }
1744
1745 #[test]
1746 fn owner_project_shorthand_expands_to_host_url() {
1747 let config = config_from("[remote \"team/app\"]\n\turl = /srv/app.git\n");
1748 let expand = |args: &[&str]| {
1749 let args: Vec<String> = args.iter().map(|s| (*s).to_owned()).collect();
1750 expand_repository_shorthand(&config, &args, "https://grithub.dev/")
1751 };
1752 assert_eq!(
1753 expand(&["me/proj", "main"]).as_deref(),
1754 Some("https://grithub.dev/me/proj.git")
1755 );
1756 assert_eq!(expand(&["topic/x"]), None);
1758 for literal in [
1760 "team/app",
1761 "origin",
1762 "./a/b",
1763 "a/b/c",
1764 "x/y.git",
1765 "https://h/a/b",
1766 ] {
1767 assert_eq!(expand(&[literal, "main"]), None, "{literal}");
1768 }
1769 }
1770
1771 #[test]
1772 fn open_push_destination_reads_config_or_treats_literal_as_url() {
1773 let config = config_from(
1774 "[remote \"origin\"]\n\turl = /tmp/o.git\n[remote \"nourl\"]\n\tfetch = +refs/heads/*:refs/remotes/nourl/*\n",
1775 );
1776 let origin = open_push_destination(&config, "origin").expect("origin");
1777 assert_eq!(origin.name.as_deref(), Some("origin"));
1778 for path in ["/srv/other.git", "../srv", "./srv"] {
1779 let literal = open_push_destination(&config, path).expect("literal");
1780 assert_eq!(literal.name, None, "{path}");
1781 }
1782 assert!(matches!(
1783 open_push_destination(&config, "nourl"),
1784 Err(RemoteError::NoUrl { remote: Some(name) }) if name == "nourl"
1785 ));
1786 }
1787
1788 #[test]
1789 fn plan_context_from_advertisement_skips_peeled_entries() {
1790 let oid = |c: &str| ObjectId::from_hex(&c.repeat(40)).expect("oid");
1791 let remote_ref = |name: &str, hex: &str| RemoteRef {
1792 name: name.to_owned(),
1793 oid: oid(hex),
1794 symref_target: None,
1795 };
1796 let ctx = PushPlanContext::from_advertised_refs([
1797 remote_ref("HEAD", "1"),
1798 remote_ref("refs/heads/main", "1"),
1799 remote_ref("refs/heads/topic", "2"),
1800 remote_ref("refs/tags/v1", "3"),
1801 remote_ref("refs/tags/v1^{}", "4"),
1802 ]);
1803 let mut names: Vec<_> = ctx.remote_ref_names.iter().map(String::as_str).collect();
1804 names.sort_unstable();
1805 assert_eq!(
1806 names,
1807 [
1808 "HEAD",
1809 "refs/heads/main",
1810 "refs/heads/topic",
1811 "refs/tags/v1"
1812 ]
1813 );
1814 assert_eq!(ctx.remote_branch_tips, HashSet::from([oid("1"), oid("2")]));
1815 assert_eq!(
1816 ctx.remote_tips,
1817 Some(vec![oid("1"), oid("1"), oid("2"), oid("3")])
1818 );
1819 }
1820
1821 #[test]
1822 fn plan_context_from_empty_advertisement_knows_remote_has_no_tips() {
1823 let ctx = PushPlanContext::from_advertised_refs([]);
1824 assert_eq!(ctx.remote_tips, Some(Vec::new()));
1825 }
1826
1827 #[test]
1828 fn plan_context_is_empty_for_unconfigured_remote() {
1829 let (_tmp, repo) = init_repo();
1830 let ctx = load_push_plan_context(&repo, &ConfigSet::new(), Some("nosuch"), None)
1831 .expect("context");
1832 assert!(ctx.remote_ref_names.is_empty());
1833 assert!(ctx.remote_branch_tips.is_empty());
1834 assert_eq!(ctx.remote_tips, None);
1835 }
1836
1837 #[test]
1838 fn plan_context_for_missing_path_assumes_no_remote_objects() {
1839 let (tmp, repo) = init_repo();
1840 let missing = tmp.path().join("missing.git");
1841 let missing = missing.to_str().expect("utf-8 path");
1842 let ctx =
1843 load_push_plan_context(&repo, &ConfigSet::new(), Some(missing), None).expect("context");
1844 assert!(ctx.remote_ref_names.is_empty());
1845 assert!(ctx.remote_tips.as_ref().is_none_or(Vec::is_empty));
1846 }
1847
1848 #[test]
1849 fn tag_push_branch_label_is_tag_name_not_current_branch() {
1850 let (_tmp, repo) = init_repo();
1851 let git_dir = repo.git_dir();
1852 std::fs::write(
1853 git_dir.join("config"),
1854 r#"[remote "origin"]
1855 url = /tmp/o.git
1856"#,
1857 )
1858 .expect("config");
1859 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
1860 let oid = ObjectId::from_hex("a".repeat(40).as_str()).expect("oid");
1861 std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads");
1862 std::fs::write(
1863 git_dir.join("refs/heads/main"),
1864 format!("{}\n", oid.to_hex()),
1865 )
1866 .expect("main");
1867 std::fs::create_dir_all(git_dir.join("refs/tags")).expect("tags");
1868 std::fs::write(git_dir.join("refs/tags/v1"), format!("{}\n", oid.to_hex())).expect("tag");
1869 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
1870 let ctx = PushPlanContext::default();
1871 let resolved = resolve_push(
1872 &repo,
1873 &config,
1874 &PushRequest {
1875 remote: Some("origin".to_owned()),
1876 refspecs: vec!["v1".to_owned()],
1877 ..Default::default()
1878 },
1879 &ctx,
1880 )
1881 .expect("resolve tag push");
1882 assert_eq!(resolved.branch_label.as_deref(), Some("v1"));
1883 assert_eq!(resolved.kind, PushPlanKind::Refspecs);
1884 assert_eq!(resolved.specs[0].dst, "refs/tags/v1");
1885 }
1886
1887 fn delete_request(refspecs: &[&str]) -> PushRequest {
1888 PushRequest {
1889 remote: Some("origin".to_owned()),
1890 refspecs: strings(refspecs),
1891 delete_refs: true,
1892 ..Default::default()
1893 }
1894 }
1895
1896 #[test]
1897 fn delete_push_plans_every_plain_name() {
1898 let (_tmp, repo) = init_repo();
1899 let config = config_from("[remote \"origin\"]\n\turl = /tmp/o.git\n");
1900 let ctx = PushPlanContext {
1901 remote_ref_names: HashSet::from(["refs/tags/v1".to_owned()]),
1902 ..Default::default()
1903 };
1904 let resolved = resolve_push(
1905 &repo,
1906 &config,
1907 &delete_request(&["a", "refs/heads/b", "v1", "+c", "HEAD"]),
1908 &ctx,
1909 )
1910 .expect("resolve delete push");
1911 assert_eq!(resolved.kind, PushPlanKind::Delete);
1912 assert_eq!(resolved.branch_label, None);
1913 let dsts: Vec<_> = resolved.specs.iter().map(|s| s.dst.as_str()).collect();
1914 assert_eq!(
1915 dsts,
1916 [
1917 "refs/heads/a",
1918 "refs/heads/b",
1919 "refs/tags/v1",
1920 "refs/heads/+c",
1921 "refs/heads/HEAD"
1922 ]
1923 );
1924 assert!(resolved
1925 .specs
1926 .iter()
1927 .all(|s| s.delete && !s.force && s.src.is_none()));
1928 }
1929
1930 #[test]
1931 fn delete_push_unions_delete_list_with_refspecs() {
1932 let (_tmp, repo) = init_repo();
1933 let config = config_from("[remote \"origin\"]\n\turl = /tmp/o.git\n");
1934 let request = PushRequest {
1935 delete: strings(&["a"]),
1936 ..delete_request(&["b"])
1937 };
1938 let resolved = resolve_push(&repo, &config, &request, &PushPlanContext::default())
1939 .expect("resolve delete push");
1940 let dsts: Vec<_> = resolved.specs.iter().map(|s| s.dst.as_str()).collect();
1941 assert_eq!(dsts, ["refs/heads/a", "refs/heads/b"]);
1942 assert_eq!(
1943 resolve_push(
1944 &repo,
1945 &config,
1946 &PushRequest {
1947 delete_refs: false,
1948 ..request
1949 },
1950 &PushPlanContext::default(),
1951 )
1952 .map(|r| r.specs.len()),
1953 Ok(2)
1954 );
1955 }
1956
1957 #[test]
1958 fn delete_push_rejects_colon_refspec() {
1959 let (_tmp, repo) = init_repo();
1960 let config = config_from("[remote \"origin\"]\n\turl = /tmp/o.git\n");
1961 for spec in ["HEAD:refs/heads/release", ":b", "a:"] {
1962 let err = resolve_push(
1963 &repo,
1964 &config,
1965 &delete_request(&["ok", spec]),
1966 &PushPlanContext::default(),
1967 )
1968 .expect_err("colon refspec with --delete");
1969 assert_eq!(
1970 err,
1971 PushPlanError::DeleteNotPlainName {
1972 spec: spec.to_owned()
1973 }
1974 );
1975 assert!(err.to_string().contains(spec), "{err}");
1976 }
1977 }
1978
1979 #[test]
1980 fn delete_push_without_refs_is_an_error() {
1981 let (_tmp, repo) = init_repo();
1982 let err = resolve_push(
1983 &repo,
1984 &ConfigSet::new(),
1985 &delete_request(&[]),
1986 &PushPlanContext::default(),
1987 )
1988 .expect_err("empty delete");
1989 assert_eq!(err, PushPlanError::DeleteWithoutRefs);
1990 }
1991
1992 #[test]
1993 fn many_shorthand_refspecs_resolve_local_heads() {
1994 let (_tmp, repo) = init_repo();
1995 let git_dir = repo.git_dir();
1996 std::fs::write(
1997 git_dir.join("config"),
1998 r#"[remote "origin"]
1999 url = /tmp/o.git
2000"#,
2001 )
2002 .expect("config");
2003 std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
2004 let oid = ObjectId::from_hex("a".repeat(40).as_str()).expect("oid");
2005 std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads");
2006 std::fs::write(
2007 git_dir.join("refs/heads/main"),
2008 format!("{}\n", oid.to_hex()),
2009 )
2010 .expect("main");
2011 for i in 1..=500 {
2012 std::fs::write(
2013 git_dir.join(format!("refs/heads/b{i}")),
2014 format!("{}\n", oid.to_hex()),
2015 )
2016 .expect("branch");
2017 }
2018 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
2019 let refspecs: Vec<String> = (1..=500).map(|i| format!("b{i}")).collect();
2020 let resolved = resolve_push(
2021 &repo,
2022 &config,
2023 &PushRequest {
2024 remote: Some("origin".to_owned()),
2025 refspecs,
2026 ..Default::default()
2027 },
2028 &PushPlanContext::default(),
2029 )
2030 .expect("resolve many shorthands");
2031 assert_eq!(resolved.specs.len(), 500);
2032 for (i, spec) in resolved.specs.iter().enumerate() {
2033 let n = i + 1;
2034 assert_eq!(spec.dst, format!("refs/heads/b{n}"));
2035 assert_eq!(spec.src, Some(oid));
2036 assert_eq!(
2037 spec.src_ref.as_deref(),
2038 Some(format!("refs/heads/b{n}").as_str())
2039 );
2040 }
2041 }
2042
2043 #[test]
2044 fn shorthand_ambiguous_head_and_tag_same_name() {
2045 let (_tmp, repo) = init_repo();
2046 let git_dir = repo.git_dir();
2047 std::fs::write(
2048 git_dir.join("config"),
2049 r#"[remote "origin"]
2050 url = /tmp/o.git
2051"#,
2052 )
2053 .expect("config");
2054 let oid = ObjectId::from_hex("b".repeat(40).as_str()).expect("oid");
2055 std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads");
2056 std::fs::create_dir_all(git_dir.join("refs/tags")).expect("tags");
2057 std::fs::write(
2058 git_dir.join("refs/heads/release"),
2059 format!("{}\n", oid.to_hex()),
2060 )
2061 .expect("head");
2062 std::fs::write(
2063 git_dir.join("refs/tags/release"),
2064 format!("{}\n", oid.to_hex()),
2065 )
2066 .expect("tag");
2067 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
2068 let err = resolve_push(
2069 &repo,
2070 &config,
2071 &PushRequest {
2072 remote: Some("origin".to_owned()),
2073 refspecs: vec!["release".to_owned()],
2074 ..Default::default()
2075 },
2076 &PushPlanContext::default(),
2077 )
2078 .expect_err("ambiguous");
2079 assert_eq!(
2080 err,
2081 PushPlanError::SrcRefspecAmbiguous {
2082 spec: "release".into()
2083 }
2084 );
2085 }
2086
2087 #[test]
2088 fn shorthand_honors_remote_push_mapping() {
2089 let (_tmp, repo) = init_repo();
2090 let git_dir = repo.git_dir();
2091 std::fs::write(
2092 git_dir.join("config"),
2093 r#"[remote "origin"]
2094 url = /tmp/o.git
2095 push = refs/heads/main:refs/remotes/origin/main
2096"#,
2097 )
2098 .expect("config");
2099 let oid = ObjectId::from_hex("c".repeat(40).as_str()).expect("oid");
2100 std::fs::create_dir_all(git_dir.join("refs/heads")).expect("heads");
2101 std::fs::write(
2102 git_dir.join("refs/heads/main"),
2103 format!("{}\n", oid.to_hex()),
2104 )
2105 .expect("main");
2106 let config = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("config");
2107 let resolved = resolve_push(
2108 &repo,
2109 &config,
2110 &PushRequest {
2111 remote: Some("origin".to_owned()),
2112 refspecs: vec!["main".to_owned()],
2113 ..Default::default()
2114 },
2115 &PushPlanContext::default(),
2116 )
2117 .expect("mapped shorthand");
2118 assert_eq!(resolved.specs.len(), 1);
2119 assert_eq!(resolved.specs[0].dst, "refs/remotes/origin/main");
2120 assert_eq!(resolved.specs[0].src, Some(oid));
2121 }
2122}