1use std::collections::HashMap;
22#[cfg(feature = "http")]
23use std::io::Read;
24use std::path::{Path, PathBuf};
25
26use sley_config::GitConfig;
27use sley_core::{GitError, ObjectFormat, ObjectId, Result};
28use sley_object::{Commit, ObjectType};
29use sley_odb::{FileObjectDatabase, ObjectReader, collect_reachable_object_ids};
30#[cfg(feature = "http")]
31use sley_protocol::{
32 GitService, ReceivePackFeatures, ReceivePackPushRequestOptions, parse_receive_pack_features,
33 read_receive_pack_report_status, smart_http_rpc_request_content_type,
34 smart_http_rpc_result_content_type,
35};
36use sley_protocol::{
37 PushSourceRef, ReceivePackCommand, ReceivePackCommandStatus, ReceivePackPushRequest,
38 ReceivePackReportStatus, ReceivePackRequest, ReceivePackUnpackStatus, RefAdvertisement,
39 parse_refspec, plan_push_commands,
40};
41
42use crate::pack::push_pack_roots;
43#[cfg(feature = "http")]
44use crate::pack::{PushPackRequest, build_receive_pack_body};
45use sley_refs::{FileRefStore, Ref, RefTarget};
46use sley_transport::RemoteUrl;
47#[cfg(feature = "http")]
48use sley_transport::{HttpClient, http_smart_rpc_url};
49
50use crate::{CredentialProvider, ProgressSink};
51
52pub enum PushDestination {
58 Http(RemoteUrl),
60 Ssh(RemoteUrl),
63 Git(RemoteUrl),
65 Local {
67 git_dir: PathBuf,
69 common_git_dir: PathBuf,
71 },
72}
73
74#[derive(Debug, Clone, Copy, Default)]
84pub struct PushOptions {
85 pub quiet: bool,
89 pub force: bool,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct PushCommand {
97 pub src: Option<ObjectId>,
99 pub dst: String,
101 pub expected_old: Option<ObjectId>,
105 pub force: bool,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum PushAction {
114 Create {
115 dst: String,
116 new: ObjectId,
117 },
118 Update {
119 dst: String,
120 old: ObjectId,
121 new: ObjectId,
122 },
123 Delete {
124 dst: String,
125 old: Option<ObjectId>,
126 },
127}
128
129impl From<PushAction> for PushCommand {
130 fn from(value: PushAction) -> Self {
131 match value {
132 PushAction::Create { dst, new } => Self {
133 src: Some(new),
134 dst,
135 expected_old: None,
136 force: false,
137 },
138 PushAction::Update { dst, old, new } => Self {
139 src: Some(new),
140 dst,
141 expected_old: Some(old),
142 force: false,
143 },
144 PushAction::Delete { dst, old } => Self {
145 src: None,
146 dst,
147 expected_old: old,
148 force: false,
149 },
150 }
151 }
152}
153
154#[derive(Debug, Clone)]
157pub struct PushActionPlan {
158 pub commands: Vec<PushCommand>,
159 pub pack_objects: Vec<ObjectId>,
160 pub options: PushOptions,
161}
162
163impl PushActionPlan {
164 pub fn from_actions(actions: Vec<PushAction>, options: PushOptions) -> Self {
165 Self {
166 commands: actions.into_iter().map(PushCommand::from).collect(),
167 pack_objects: Vec::new(),
168 options,
169 }
170 }
171
172 pub fn from_commands(commands: Vec<PushCommand>, options: PushOptions) -> Self {
173 Self {
174 commands,
175 pack_objects: Vec::new(),
176 options,
177 }
178 }
179
180 pub fn from_commands_and_infer_pack_roots(
181 commands: Vec<PushCommand>,
182 options: PushOptions,
183 ) -> Self {
184 let mut pack_objects = Vec::new();
185 for command in &commands {
186 let Some(src) = command.src.as_ref() else {
187 continue;
188 };
189 if !pack_objects.contains(src) {
190 pack_objects.push(*src);
191 }
192 }
193 Self {
194 commands,
195 pack_objects,
196 options,
197 }
198 }
199}
200
201#[derive(Debug, Clone, Default)]
203pub struct PushOutcome {
204 pub commands: Vec<ReceivePackCommand>,
209 pub report: Option<ReceivePackReportStatus>,
214}
215
216#[derive(Clone, Copy)]
218pub struct PushRequest<'a> {
219 pub git_dir: &'a Path,
221 pub common_git_dir: &'a Path,
223 pub format: ObjectFormat,
225 pub config: &'a GitConfig,
227 pub remote: &'a str,
229 pub destination: &'a PushDestination,
231 pub refspecs: &'a [String],
233 pub options: &'a PushOptions,
235}
236
237#[derive(Clone, Copy)]
239pub struct PushActionRequest<'a> {
240 pub git_dir: &'a Path,
242 pub common_git_dir: &'a Path,
244 pub format: ObjectFormat,
246 pub config: &'a GitConfig,
248 pub remote: &'a str,
250 pub destination: &'a PushDestination,
252 pub plan: &'a PushActionPlan,
254}
255
256pub struct PushServices<'a> {
258 pub credentials: &'a mut dyn CredentialProvider,
260 pub progress: &'a mut dyn ProgressSink,
262}
263
264pub struct PushPlan {
267 pub commands: Vec<ReceivePackCommand>,
269 execution: PushExecution,
270}
271
272enum PushExecution {
273 Noop,
274 #[cfg(feature = "http")]
275 Http {
276 remote_url: RemoteUrl,
277 features: ReceivePackFeatures,
278 advertisements: Vec<RefAdvertisement>,
279 pack_objects: Vec<ObjectId>,
280 },
281 Ssh(crate::ssh::SshPushPlan),
282 Git(crate::git::GitPushPlan),
283 Local {
284 remote_git_dir: PathBuf,
285 remote_common_git_dir: PathBuf,
286 remote_refs: Vec<RefAdvertisement>,
287 command_forces: Vec<(ReceivePackCommand, bool)>,
288 pack_objects: Vec<ObjectId>,
289 },
290}
291
292pub fn push(request: PushRequest<'_>, mut services: PushServices<'_>) -> Result<PushOutcome> {
307 let plan = plan_push(request, &mut services)?;
308 execute_push_plan(request, &mut services, plan)
309}
310
311pub fn push_actions(
313 request: PushActionRequest<'_>,
314 mut services: PushServices<'_>,
315) -> Result<PushOutcome> {
316 let plan = plan_push_actions(request, &mut services)?;
317 execute_push_action_plan(request, &mut services, plan)
318}
319
320pub fn plan_push(request: PushRequest<'_>, services: &mut PushServices<'_>) -> Result<PushPlan> {
323 let _ = request.config;
328 let _ = &mut services.progress;
329 match request.destination {
330 #[cfg(feature = "http")]
331 PushDestination::Http(remote_url) => plan_push_http(PushHttpRequest {
332 git_dir: request.git_dir,
333 common_git_dir: request.common_git_dir,
334 format: request.format,
335 remote_url,
336 refspecs: request.refspecs,
337 options: request.options,
338 credentials: services.credentials,
339 }),
340 #[cfg(not(feature = "http"))]
341 PushDestination::Http(_) => Err(GitError::Unsupported(
342 "HTTP transport is not enabled in this build".into(),
343 )),
344 PushDestination::Ssh(remote_url) => {
345 let plan = crate::ssh::plan_push_ssh(crate::ssh::SshPushRequest {
346 git_dir: request.git_dir,
347 common_git_dir: request.common_git_dir,
348 format: request.format,
349 remote: remote_url,
350 refspecs: request.refspecs,
351 force: request.options.force,
352 })?;
353 let commands = plan.commands.clone();
354 let execution = if commands.is_empty() {
355 PushExecution::Noop
356 } else {
357 PushExecution::Ssh(plan)
358 };
359 Ok(PushPlan {
360 commands,
361 execution,
362 })
363 }
364 PushDestination::Git(remote_url) => {
365 let plan = crate::git::plan_push_git(crate::git::GitPushRequest {
366 git_dir: request.git_dir,
367 common_git_dir: request.common_git_dir,
368 format: request.format,
369 remote: remote_url,
370 refspecs: request.refspecs,
371 force: request.options.force,
372 })?;
373 let commands = plan.commands.clone();
374 let execution = if commands.is_empty() {
375 PushExecution::Noop
376 } else {
377 PushExecution::Git(plan)
378 };
379 Ok(PushPlan {
380 commands,
381 execution,
382 })
383 }
384 PushDestination::Local {
385 git_dir: remote_git_dir,
386 common_git_dir: remote_common_git_dir,
387 } => plan_push_local(PushLocalRequest {
388 git_dir: request.git_dir,
389 common_git_dir: request.common_git_dir,
390 format: request.format,
391 remote: request.remote,
392 remote_git_dir,
393 remote_common_git_dir,
394 refspecs: request.refspecs,
395 options: request.options,
396 }),
397 }
398}
399
400pub fn plan_push_actions(
403 request: PushActionRequest<'_>,
404 services: &mut PushServices<'_>,
405) -> Result<PushPlan> {
406 let _ = request.config;
407 let _ = &mut services.progress;
408 let commands = receive_pack_commands_from_action_plan(request.format, request.plan)?;
409 let command_forces = commands
410 .iter()
411 .cloned()
412 .zip(request.plan.commands.iter())
413 .map(|(command, planned)| (command, request.plan.options.force || planned.force))
414 .collect::<Vec<_>>();
415 match request.destination {
416 #[cfg(feature = "http")]
417 PushDestination::Http(remote_url) => {
418 let client = crate::http::new_http_client();
419 let discovered = crate::http::http_service_advertisements(
420 &client,
421 remote_url,
422 request.format,
423 GitService::ReceivePack,
424 services.credentials,
425 )?;
426 let advertisement_set = discovered.set;
427 let features = advertised_receive_pack_features(&advertisement_set.refs)?;
428 verify_remote_object_format(&features, request.format)?;
429 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
430 reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
431 let execution = if commands.is_empty() {
432 PushExecution::Noop
433 } else {
434 PushExecution::Http {
435 remote_url: remote_url.clone(),
436 features,
437 advertisements: advertisement_set.refs,
438 pack_objects: request.plan.pack_objects.clone(),
439 }
440 };
441 Ok(PushPlan {
442 commands,
443 execution,
444 })
445 }
446 #[cfg(not(feature = "http"))]
447 PushDestination::Http(_) => Err(GitError::Unsupported(
448 "HTTP transport is not enabled in this build".into(),
449 )),
450 PushDestination::Ssh(remote_url) => {
451 let plan = crate::ssh::plan_push_ssh_commands(crate::ssh::SshPushCommandsRequest {
452 common_git_dir: request.common_git_dir,
453 format: request.format,
454 remote: remote_url,
455 command_forces: command_forces.clone(),
456 pack_objects: request.plan.pack_objects.clone(),
457 })?;
458 let commands = plan.commands.clone();
459 let execution = if commands.is_empty() {
460 PushExecution::Noop
461 } else {
462 PushExecution::Ssh(plan)
463 };
464 Ok(PushPlan {
465 commands,
466 execution,
467 })
468 }
469 PushDestination::Git(remote_url) => {
470 let plan = crate::git::plan_push_git_commands(crate::git::GitPushCommandsRequest {
471 common_git_dir: request.common_git_dir,
472 format: request.format,
473 remote: remote_url,
474 command_forces: command_forces.clone(),
475 pack_objects: request.plan.pack_objects.clone(),
476 })?;
477 let commands = plan.commands.clone();
478 let execution = if commands.is_empty() {
479 PushExecution::Noop
480 } else {
481 PushExecution::Git(plan)
482 };
483 Ok(PushPlan {
484 commands,
485 execution,
486 })
487 }
488 PushDestination::Local {
489 git_dir: remote_git_dir,
490 common_git_dir: remote_common_git_dir,
491 } => {
492 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
493 if remote_format != request.format {
494 return Err(GitError::InvalidObjectId(format!(
495 "remote repository uses {}, local repository uses {}",
496 remote_format.name(),
497 request.format.name()
498 )));
499 }
500 let remote_refs =
501 crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
502 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
503 reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
504 let execution = if commands.is_empty() {
505 PushExecution::Noop
506 } else {
507 PushExecution::Local {
508 remote_git_dir: remote_git_dir.to_path_buf(),
509 remote_common_git_dir: remote_common_git_dir.to_path_buf(),
510 remote_refs,
511 command_forces,
512 pack_objects: request.plan.pack_objects.clone(),
513 }
514 };
515 Ok(PushPlan {
516 commands,
517 execution,
518 })
519 }
520 }
521}
522
523pub fn execute_push_plan(
525 request: PushRequest<'_>,
526 services: &mut PushServices<'_>,
527 plan: PushPlan,
528) -> Result<PushOutcome> {
529 let _ = (request.config, request.remote);
530 let _ = &mut services.progress;
531 if plan.commands.is_empty() {
532 return Ok(PushOutcome::default());
533 }
534 match plan.execution {
535 PushExecution::Noop => Ok(PushOutcome::default()),
536 #[cfg(feature = "http")]
537 PushExecution::Http {
538 remote_url,
539 features,
540 advertisements,
541 pack_objects,
542 } => execute_push_http(
543 request,
544 services.credentials,
545 plan.commands,
546 remote_url,
547 features,
548 advertisements,
549 pack_objects,
550 ),
551 PushExecution::Ssh(plan) => crate::ssh::execute_push_ssh_plan(request, plan),
552 PushExecution::Git(plan) => crate::git::execute_push_git_plan(request, plan),
553 PushExecution::Local {
554 remote_git_dir,
555 remote_common_git_dir,
556 remote_refs,
557 command_forces,
558 pack_objects,
559 } => execute_push_local(
560 request,
561 plan.commands,
562 remote_git_dir,
563 remote_common_git_dir,
564 remote_refs,
565 command_forces,
566 pack_objects,
567 ),
568 }
569}
570
571pub fn execute_push_action_plan(
573 request: PushActionRequest<'_>,
574 services: &mut PushServices<'_>,
575 plan: PushPlan,
576) -> Result<PushOutcome> {
577 let refspecs: &[String] = &[];
578 execute_push_plan(
579 PushRequest {
580 git_dir: request.git_dir,
581 common_git_dir: request.common_git_dir,
582 format: request.format,
583 config: request.config,
584 remote: request.remote,
585 destination: request.destination,
586 refspecs,
587 options: &request.plan.options,
588 },
589 services,
590 plan,
591 )
592}
593
594#[cfg(feature = "http")]
597struct PushHttpRequest<'a> {
598 git_dir: &'a Path,
599 common_git_dir: &'a Path,
600 format: ObjectFormat,
601 remote_url: &'a RemoteUrl,
602 refspecs: &'a [String],
603 options: &'a PushOptions,
604 credentials: &'a mut dyn CredentialProvider,
605}
606
607#[cfg(feature = "http")]
608fn plan_push_http(request: PushHttpRequest<'_>) -> Result<PushPlan> {
609 let PushHttpRequest {
610 git_dir,
611 common_git_dir,
612 format,
613 remote_url,
614 refspecs,
615 options,
616 credentials,
617 } = request;
618 let client = crate::http::new_http_client();
619 let discovered = crate::http::http_service_advertisements(
620 &client,
621 remote_url,
622 format,
623 GitService::ReceivePack,
624 credentials,
625 )?;
626 let advertisement_set = discovered.set;
627 let features = advertised_receive_pack_features(&advertisement_set.refs)?;
628 verify_remote_object_format(&features, format)?;
629
630 let local_store = FileRefStore::new(git_dir, format);
631 let mut local_refs = local_push_source_refs(&local_store, format)?;
632 add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
633 let command_forces = plan_push_command_forces(
634 format,
635 &local_refs,
636 &advertisement_set.refs,
637 refspecs,
638 options.force,
639 )?;
640 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
641 reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
642 let commands = commands_from_forces(&command_forces);
643 let execution = if commands.is_empty() {
644 PushExecution::Noop
645 } else {
646 PushExecution::Http {
647 remote_url: remote_url.clone(),
648 features,
649 advertisements: advertisement_set.refs,
650 pack_objects: Vec::new(),
651 }
652 };
653 Ok(PushPlan {
654 commands,
655 execution,
656 })
657}
658
659#[cfg(feature = "http")]
660fn execute_push_http(
661 request: PushRequest<'_>,
662 credentials: &mut dyn CredentialProvider,
663 commands: Vec<ReceivePackCommand>,
664 remote_url: RemoteUrl,
665 features: ReceivePackFeatures,
666 advertisements: Vec<RefAdvertisement>,
667 pack_objects: Vec<ObjectId>,
668) -> Result<PushOutcome> {
669 let client = crate::http::new_http_client();
670 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
671 let body = build_receive_pack_body(&PushPackRequest {
672 local_db: &local_db,
673 format: request.format,
674 commands: &commands,
675 pack_objects: &pack_objects,
676 remote_advertisements: &advertisements,
677 features: &features,
678 options: receive_pack_push_options(&features, request.format, request.options.quiet),
679 thin: false,
680 })?;
681 let url = http_smart_rpc_url(&remote_url, GitService::ReceivePack)?;
682 let content_type = smart_http_rpc_request_content_type(GitService::ReceivePack)?;
683 let mut response = crate::http::http_send_with_auth(&remote_url, credentials, |auth| {
684 client.post(
685 &url,
686 &content_type,
687 &crate::http::http_authorization_headers(auth),
688 &body,
689 )
690 })?;
691 crate::http::http_check_status(&response, &url)?;
692 crate::http::http_validate_content_type(
693 &response,
694 &smart_http_rpc_result_content_type(GitService::ReceivePack)?,
695 )?;
696
697 let report = if features.report_status {
698 let report = read_receive_pack_report_status(&mut response.body)?;
699 validate_receive_pack_report(&report)?;
700 Some(report)
701 } else {
702 let mut sink = Vec::new();
703 response.body.read_to_end(&mut sink)?;
704 None
705 };
706 Ok(PushOutcome { commands, report })
707}
708
709struct PushLocalRequest<'a> {
713 git_dir: &'a Path,
714 common_git_dir: &'a Path,
715 format: ObjectFormat,
716 remote: &'a str,
717 remote_git_dir: &'a Path,
718 remote_common_git_dir: &'a Path,
719 refspecs: &'a [String],
720 options: &'a PushOptions,
721}
722
723fn plan_push_local(request: PushLocalRequest<'_>) -> Result<PushPlan> {
724 let PushLocalRequest {
725 git_dir,
726 common_git_dir,
727 format,
728 remote,
729 remote_git_dir,
730 remote_common_git_dir,
731 refspecs,
732 options,
733 } = request;
734 let _ = remote;
735 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
736 if remote_format != format {
737 return Err(GitError::InvalidObjectId(format!(
738 "remote repository uses {}, local repository uses {}",
739 remote_format.name(),
740 format.name()
741 )));
742 }
743
744 let local_store = FileRefStore::new(git_dir, format);
745 let mut local_refs = local_push_source_refs(&local_store, format)?;
746 add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
747 let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
748 let command_forces =
749 plan_push_command_forces(format, &local_refs, &remote_refs, refspecs, options.force)?;
750 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
751 reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
752 let commands = commands_from_forces(&command_forces);
753 let execution = if commands.is_empty() {
754 PushExecution::Noop
755 } else {
756 PushExecution::Local {
757 remote_git_dir: remote_git_dir.to_path_buf(),
758 remote_common_git_dir: remote_common_git_dir.to_path_buf(),
759 remote_refs,
760 command_forces,
761 pack_objects: Vec::new(),
762 }
763 };
764 Ok(PushPlan {
765 commands,
766 execution,
767 })
768}
769
770fn execute_push_local(
771 request: PushRequest<'_>,
772 commands: Vec<ReceivePackCommand>,
773 remote_git_dir: PathBuf,
774 remote_common_git_dir: PathBuf,
775 remote_refs: Vec<RefAdvertisement>,
776 _command_forces: Vec<(ReceivePackCommand, bool)>,
777 pack_objects: Vec<ObjectId>,
778) -> Result<PushOutcome> {
779 let remote_excluded_tips = remote_refs
780 .iter()
781 .map(|reference| reference.oid)
782 .collect::<Vec<_>>();
783 let starts = push_pack_roots(&commands, &pack_objects);
784 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
785 let remote_db = FileObjectDatabase::from_git_dir(&remote_common_git_dir, request.format);
786 let remote_excluded =
787 collect_reachable_object_ids(&remote_db, request.format, remote_excluded_tips)?;
788 let packfile = if starts.is_empty() {
789 Vec::new()
790 } else {
791 b"PACK".to_vec()
792 };
793 let receive_request = ReceivePackPushRequest {
794 commands: ReceivePackRequest {
795 shallow: Vec::new(),
796 commands: commands.clone(),
797 capabilities: Vec::new(),
798 },
799 push_options: None,
800 packfile,
801 };
802 let report = crate::local::receive_pack_reachable_pack_into_local_repository(
803 &remote_git_dir,
804 request.format,
805 &receive_request,
806 &local_db,
807 starts,
808 remote_excluded,
809 )?;
810 validate_receive_pack_report(&report)?;
811 Ok(PushOutcome {
812 commands,
813 report: Some(report),
814 })
815}
816
817#[cfg(feature = "http")]
820fn advertised_receive_pack_features(
821 advertisements: &[RefAdvertisement],
822) -> Result<ReceivePackFeatures> {
823 advertisements
824 .first()
825 .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
826 .transpose()
827 .map(Option::unwrap_or_default)
828}
829
830#[cfg(feature = "http")]
833fn verify_remote_object_format(features: &ReceivePackFeatures, format: ObjectFormat) -> Result<()> {
834 if let Some(remote_format) = features.object_format {
835 if remote_format != format {
836 return Err(GitError::InvalidObjectId(format!(
837 "remote repository uses {}, local repository uses {}",
838 remote_format.name(),
839 format.name()
840 )));
841 }
842 } else if format != ObjectFormat::Sha1 {
843 return Err(GitError::InvalidObjectId(format!(
844 "remote repository did not advertise object-format for {} push",
845 format.name()
846 )));
847 }
848 Ok(())
849}
850
851#[cfg(feature = "http")]
856fn receive_pack_push_options(
857 features: &ReceivePackFeatures,
858 format: ObjectFormat,
859 quiet: bool,
860) -> ReceivePackPushRequestOptions {
861 ReceivePackPushRequestOptions {
862 report_status: features.report_status,
863 ofs_delta: features.ofs_delta,
864 quiet: quiet && features.quiet,
865 object_format: features
866 .object_format
867 .filter(|_| format != ObjectFormat::Sha1),
868 ..ReceivePackPushRequestOptions::default()
869 }
870}
871
872fn plan_push_command_forces(
877 format: ObjectFormat,
878 local_refs: &[PushSourceRef],
879 remote_refs: &[RefAdvertisement],
880 refspecs: &[String],
881 force: bool,
882) -> Result<Vec<(ReceivePackCommand, bool)>> {
883 let parsed_refspecs = refspecs
884 .iter()
885 .map(|refspec| {
886 let normalized =
887 normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
888 parse_refspec(&normalized)
889 })
890 .collect::<Result<Vec<_>>>()?;
891 let mut command_forces = Vec::new();
892 for refspec in &parsed_refspecs {
893 for command in plan_push_commands(
894 format,
895 local_refs,
896 remote_refs,
897 std::slice::from_ref(refspec),
898 )? {
899 command_forces.push((command, force || refspec.force));
900 }
901 }
902 Ok(command_forces)
903}
904
905fn add_revision_push_sources(
906 git_dir: &Path,
907 format: ObjectFormat,
908 refspecs: &[String],
909 local_refs: &mut Vec<PushSourceRef>,
910) {
911 for refspec in refspecs {
912 let refspec = refspec.strip_prefix('+').unwrap_or(refspec);
913 let src = refspec.split_once(':').map_or(refspec, |(src, _)| src);
914 if src.is_empty() || src == "HEAD" || src.starts_with("refs/") {
915 continue;
916 }
917 if local_refs.iter().any(|reference| {
918 reference.name == src
919 || reference.name == format!("refs/heads/{src}")
920 || reference.name == format!("refs/tags/{src}")
921 }) {
922 continue;
923 }
924 if let Ok(oid) = sley_rev::resolve_revision(git_dir, format, src)
925 && !local_refs.iter().any(|reference| reference.name == src)
926 {
927 local_refs.push(PushSourceRef {
928 name: src.to_string(),
929 oid,
930 });
931 }
932 }
933}
934
935fn normalize_push_refspec_for_sources(
936 refspec: &str,
937 local_refs: &[PushSourceRef],
938 remote_refs: &[RefAdvertisement],
939) -> Result<String> {
940 let (force, refspec) = refspec
941 .strip_prefix('+')
942 .map_or((false, refspec), |refspec| (true, refspec));
943 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
944 let (src, src_kind) = normalize_push_source_refname(src, local_refs);
945 let dst = normalize_push_destination_refname(dst, src_kind, remote_refs)?;
946 format!("{src}:{dst}")
947 } else {
948 let (name, _) = normalize_push_source_refname(refspec, local_refs);
949 let dst = match count_refspec_match_dst(&name, remote_refs) {
956 DstMatch::Unique(matched) => matched.to_string(),
957 DstMatch::None => name.clone(),
958 DstMatch::Ambiguous => {
959 return Err(GitError::Command(format!(
960 "dst refspec {name} matches more than one"
961 )));
962 }
963 };
964 format!("{name}:{dst}")
965 };
966 Ok(if force {
967 format!("+{normalized}")
968 } else {
969 normalized
970 })
971}
972
973fn refname_match_rank(abbrev: &str, full_name: &str) -> Option<usize> {
977 const RULES: [&str; 6] = [
978 "{}",
979 "refs/{}",
980 "refs/tags/{}",
981 "refs/heads/{}",
982 "refs/remotes/{}",
983 "refs/remotes/{}/HEAD",
984 ];
985 for (idx, rule) in RULES.iter().enumerate() {
986 let (prefix, suffix) = rule.split_once("{}").unwrap_or((rule, ""));
987 if full_name == format!("{prefix}{abbrev}{suffix}") {
988 return Some(RULES.len() - idx);
989 }
990 }
991 None
992}
993
994enum DstMatch<'a> {
996 Unique(&'a str),
998 None,
1000 Ambiguous,
1002}
1003
1004fn count_refspec_match_dst<'a>(pattern: &str, remote_refs: &'a [RefAdvertisement]) -> DstMatch<'a> {
1011 let patlen = pattern.len();
1012 let mut strong: Option<&str> = None;
1013 let mut strong_count = 0usize;
1014 let mut weak: Option<&str> = None;
1015 let mut weak_count = 0usize;
1016 for advert in remote_refs {
1017 let name = advert.name.as_str();
1018 if refname_match_rank(pattern, name).is_none() {
1019 continue;
1020 }
1021 let namelen = name.len();
1022 let is_weak = namelen != patlen
1023 && patlen + 5 != namelen
1024 && !name.starts_with("refs/heads/")
1025 && !name.starts_with("refs/tags/");
1026 if is_weak {
1027 weak = Some(name);
1028 weak_count += 1;
1029 } else {
1030 strong = Some(name);
1031 strong_count += 1;
1032 }
1033 }
1034 match (strong_count, weak_count, strong, weak) {
1035 (1, _, Some(matched), _) => DstMatch::Unique(matched),
1036 (0, 1, _, Some(matched)) => DstMatch::Unique(matched),
1037 (0, 0, _, _) => DstMatch::None,
1038 _ => DstMatch::Ambiguous,
1039 }
1040}
1041
1042#[derive(Clone, Copy)]
1043enum PushSourceKind {
1044 Branch,
1045 Tag,
1046 Other,
1047}
1048
1049fn normalize_push_source_refname(
1050 name: &str,
1051 local_refs: &[PushSourceRef],
1052) -> (String, PushSourceKind) {
1053 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1054 return (name.to_string(), PushSourceKind::Other);
1055 }
1056 let branch = format!("refs/heads/{name}");
1057 let tag = format!("refs/tags/{name}");
1058 let has_branch = local_refs.iter().any(|reference| reference.name == branch);
1059 let has_tag = local_refs.iter().any(|reference| reference.name == tag);
1060 if has_tag && !has_branch {
1061 (tag, PushSourceKind::Tag)
1062 } else if has_branch {
1063 (branch, PushSourceKind::Branch)
1064 } else if local_refs.iter().any(|reference| reference.name == name) {
1065 (name.to_string(), PushSourceKind::Other)
1066 } else {
1067 (branch, PushSourceKind::Branch)
1068 }
1069}
1070
1071fn normalize_push_destination_refname(
1072 name: &str,
1073 src_kind: PushSourceKind,
1074 remote_refs: &[RefAdvertisement],
1075) -> Result<String> {
1076 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1077 return Ok(name.to_string());
1078 }
1079 match count_refspec_match_dst(name, remote_refs) {
1085 DstMatch::Unique(matched) => Ok(matched.to_string()),
1086 DstMatch::Ambiguous => Err(GitError::Command(format!(
1087 "dst refspec {name} matches more than one"
1088 ))),
1089 DstMatch::None => Ok(match src_kind {
1090 PushSourceKind::Tag => format!("refs/tags/{name}"),
1091 PushSourceKind::Branch | PushSourceKind::Other => format!("refs/heads/{name}"),
1092 }),
1093 }
1094}
1095
1096fn commands_from_forces(command_forces: &[(ReceivePackCommand, bool)]) -> Vec<ReceivePackCommand> {
1098 command_forces
1099 .iter()
1100 .map(|(command, _)| command.clone())
1101 .collect()
1102}
1103
1104fn receive_pack_commands_from_action_plan(
1105 format: ObjectFormat,
1106 plan: &PushActionPlan,
1107) -> Result<Vec<ReceivePackCommand>> {
1108 let zero = ObjectId::null(format);
1109 for oid in &plan.pack_objects {
1110 if oid.format() != format {
1111 return Err(GitError::InvalidObjectId(format!(
1112 "push pack object {oid} has {} object id for {} repository",
1113 oid.format().name(),
1114 format.name()
1115 )));
1116 }
1117 }
1118 plan.commands
1119 .iter()
1120 .map(|command| {
1121 let old_id = command.expected_old.unwrap_or(zero);
1122 let new_id = command.src.unwrap_or(zero);
1123 if old_id.format() != format {
1124 return Err(GitError::InvalidObjectId(format!(
1125 "push command {} expected old has {} object id for {} repository",
1126 command.dst,
1127 old_id.format().name(),
1128 format.name()
1129 )));
1130 }
1131 if new_id.format() != format {
1132 return Err(GitError::InvalidObjectId(format!(
1133 "push command {} new id has {} object id for {} repository",
1134 command.dst,
1135 new_id.format().name(),
1136 format.name()
1137 )));
1138 }
1139 Ok(ReceivePackCommand {
1140 old_id,
1141 new_id,
1142 name: command.dst.clone(),
1143 })
1144 })
1145 .collect()
1146}
1147
1148pub fn validate_receive_pack_report(report: &ReceivePackReportStatus) -> Result<()> {
1151 if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1152 return Err(GitError::Command(format!(
1153 "failed to push some refs: unpack failed: {message}"
1154 )));
1155 }
1156 for status in &report.commands {
1157 if let ReceivePackCommandStatus::Ng { name, message } = status {
1158 return Err(GitError::Command(format!(
1159 "failed to push {name}: {message}"
1160 )));
1161 }
1162 }
1163 Ok(())
1164}
1165
1166pub fn local_push_source_refs(
1170 store: &FileRefStore,
1171 format: ObjectFormat,
1172) -> Result<Vec<PushSourceRef>> {
1173 let mut refs = Vec::new();
1174 for reference in store.list_refs()? {
1175 let Some((oid, _)) = resolve_for_each_ref_target(store, &reference)? else {
1176 continue;
1177 };
1178 if oid.format() != format {
1179 return Err(GitError::InvalidObjectId(format!(
1180 "local ref {} has {} object id for {} repository",
1181 reference.name,
1182 oid.format().name(),
1183 format.name()
1184 )));
1185 }
1186 refs.push(PushSourceRef {
1187 name: reference.name.clone(),
1188 oid,
1189 });
1190 if let Some(short) = reference.name.strip_prefix("refs/heads/") {
1191 refs.push(PushSourceRef {
1192 name: short.to_string(),
1193 oid,
1194 });
1195 }
1196 if let Some(short) = reference.name.strip_prefix("refs/tags/") {
1197 refs.push(PushSourceRef {
1198 name: short.to_string(),
1199 oid,
1200 });
1201 }
1202 }
1203 if let Some(target) = store.read_ref("HEAD")? {
1204 let head = Ref {
1205 name: "HEAD".to_string(),
1206 target,
1207 };
1208 if let Some((oid, _)) = resolve_for_each_ref_target(store, &head)?
1209 && oid.format() == format
1210 {
1211 refs.push(PushSourceRef {
1212 name: "HEAD".to_string(),
1213 oid,
1214 });
1215 }
1216 }
1217 Ok(refs)
1218}
1219
1220pub fn normalize_push_refspec(refspec: &str) -> String {
1224 let (force, refspec) = refspec
1225 .strip_prefix('+')
1226 .map_or((false, refspec), |refspec| (true, refspec));
1227 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1228 let src = normalize_push_refname(src);
1229 let dst = normalize_push_refname(dst);
1230 format!("{src}:{dst}")
1231 } else {
1232 let name = normalize_push_refname(refspec);
1233 format!("{name}:{name}")
1234 };
1235 if force {
1236 format!("+{normalized}")
1237 } else {
1238 normalized
1239 }
1240}
1241
1242pub fn normalize_push_refname(name: &str) -> String {
1245 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1246 name.to_string()
1247 } else {
1248 format!("refs/heads/{name}")
1249 }
1250}
1251
1252pub fn reject_non_fast_forward_pushes(
1256 local_db: &FileObjectDatabase,
1257 format: ObjectFormat,
1258 command_forces: &[(ReceivePackCommand, bool)],
1259) -> Result<()> {
1260 for (command, force) in command_forces {
1261 if *force
1262 || !command.name.starts_with("refs/heads/")
1263 || command.old_id.is_null()
1264 || command.new_id.is_null()
1265 {
1266 continue;
1267 }
1268 let ancestors = ancestor_depths(local_db, format, &command.new_id)?;
1269 if !ancestors.contains_key(&command.old_id) {
1270 let short = command.name.trim_start_matches("refs/heads/");
1271 return Err(GitError::Command(format!(
1272 "failed to push some refs: non-fast-forward update to {short}"
1273 )));
1274 }
1275 }
1276 Ok(())
1277}
1278
1279fn ancestor_depths(
1283 db: &FileObjectDatabase,
1284 format: ObjectFormat,
1285 start: &ObjectId,
1286) -> Result<HashMap<ObjectId, usize>> {
1287 let mut depths = HashMap::new();
1288 let mut pending = std::collections::VecDeque::from([(start.clone(), 0usize)]);
1289 while let Some((oid, depth)) = pending.pop_front() {
1290 if depths.get(&oid).is_some_and(|existing| *existing <= depth) {
1291 continue;
1292 }
1293 depths.insert(oid, depth);
1294 let object = db.read_object(&oid)?;
1295 if object.object_type != ObjectType::Commit {
1296 return Err(GitError::InvalidObject(format!(
1297 "expected commit {oid}, found {}",
1298 object.object_type.as_str()
1299 )));
1300 }
1301 let commit = Commit::parse_ref(format, &object.body)?;
1302 for parent in commit.parents {
1303 pending.push_back((parent, depth + 1));
1304 }
1305 }
1306 Ok(depths)
1307}
1308
1309fn resolve_for_each_ref_target(
1312 store: &FileRefStore,
1313 reference: &Ref,
1314) -> Result<Option<(ObjectId, Option<String>)>> {
1315 let mut target = reference.target.clone();
1316 let mut symref = None;
1317 for _ in 0..5 {
1318 match target {
1319 RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
1320 RefTarget::Symbolic(name) => {
1321 symref.get_or_insert_with(|| name.clone());
1322 let Some(next) = store.read_ref(&name)? else {
1323 return Ok(None);
1324 };
1325 target = next;
1326 }
1327 }
1328 }
1329 Ok(None)
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334 use super::*;
1335 use std::fs;
1336 use std::sync::atomic::{AtomicU64, Ordering};
1337
1338 use sley_formats::RepositoryLayout;
1339 use sley_object::{Commit, EncodedObject, ObjectType, Tree};
1340 use sley_odb::{FileObjectDatabase, ObjectWriter};
1341 use sley_protocol::{ReceivePackCommandStatus, ReceivePackUnpackStatus};
1342 use sley_refs::{RefTarget, RefUpdate};
1343
1344 use crate::{NoCredentials, SilentProgress};
1345
1346 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1347
1348 fn temp_repo(name: &str) -> PathBuf {
1349 let dir = std::env::temp_dir().join(format!(
1350 "sley-remote-push-{name}-{}-{}",
1351 std::process::id(),
1352 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1353 ));
1354 let _ = fs::remove_dir_all(&dir);
1355 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
1356 .expect("test repository should initialize");
1357 dir.join(".git")
1358 }
1359
1360 fn write_commit(git_dir: &Path, parents: Vec<ObjectId>, message: &str) -> ObjectId {
1361 let format = ObjectFormat::Sha1;
1362 let db = FileObjectDatabase::from_git_dir(git_dir, format);
1363 let tree = db
1364 .write_object(EncodedObject::new(
1365 ObjectType::Tree,
1366 Tree { entries: vec![] }.write(),
1367 ))
1368 .expect("tree should write");
1369 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
1370 db.write_object(EncodedObject::new(
1371 ObjectType::Commit,
1372 Commit {
1373 tree,
1374 parents,
1375 author: identity.clone(),
1376 committer: identity,
1377 encoding: None,
1378 message: format!("{message}\n").into_bytes(),
1379 }
1380 .write(),
1381 ))
1382 .expect("commit should write")
1383 }
1384
1385 fn set_ref(git_dir: &Path, name: &str, target: RefTarget) {
1386 let store = FileRefStore::new(git_dir, ObjectFormat::Sha1);
1387 let mut tx = store.transaction();
1388 tx.update(RefUpdate {
1389 name: name.to_string(),
1390 expected: None,
1391 new: target,
1392 reflog: None,
1393 });
1394 tx.commit().expect("ref should update");
1395 }
1396
1397 fn default_options() -> PushOptions {
1398 PushOptions {
1399 quiet: true,
1400 force: false,
1401 }
1402 }
1403
1404 #[test]
1405 fn push_action_plan_infers_pack_roots_from_non_delete_commands() {
1406 let repo = temp_repo("action-plan-infer-roots");
1407 let first = write_commit(&repo, Vec::new(), "first");
1408 let second = write_commit(&repo, vec![first], "second");
1409
1410 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1411 vec![
1412 PushCommand {
1413 src: Some(first),
1414 dst: "refs/heads/main".into(),
1415 expected_old: None,
1416 force: false,
1417 },
1418 PushCommand {
1419 src: Some(second),
1420 dst: "refs/heads/topic".into(),
1421 expected_old: Some(first),
1422 force: true,
1423 },
1424 ],
1425 default_options(),
1426 );
1427
1428 assert_eq!(plan.pack_objects, vec![first, second]);
1429 assert!(!plan.commands[0].force);
1430 assert!(plan.commands[1].force);
1431 }
1432
1433 #[test]
1434 fn push_action_plan_inferred_pack_roots_exclude_deletes() {
1435 let repo = temp_repo("action-plan-delete-roots");
1436 let old = write_commit(&repo, Vec::new(), "old");
1437 let new = write_commit(&repo, vec![old], "new");
1438
1439 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1440 vec![
1441 PushCommand {
1442 src: None,
1443 dst: "refs/heads/remove".into(),
1444 expected_old: Some(old),
1445 force: false,
1446 },
1447 PushCommand {
1448 src: Some(new),
1449 dst: "refs/heads/keep".into(),
1450 expected_old: Some(old),
1451 force: false,
1452 },
1453 ],
1454 default_options(),
1455 );
1456
1457 assert_eq!(plan.pack_objects, vec![new]);
1458 }
1459
1460 #[test]
1461 fn push_action_plan_inferred_pack_roots_dedupe_first_seen_order() {
1462 let repo = temp_repo("action-plan-dedupe-roots");
1463 let first = write_commit(&repo, Vec::new(), "first");
1464 let second = write_commit(&repo, Vec::new(), "second");
1465
1466 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1467 vec![
1468 PushCommand {
1469 src: Some(second),
1470 dst: "refs/heads/second".into(),
1471 expected_old: None,
1472 force: false,
1473 },
1474 PushCommand {
1475 src: Some(first),
1476 dst: "refs/heads/first".into(),
1477 expected_old: None,
1478 force: false,
1479 },
1480 PushCommand {
1481 src: Some(second),
1482 dst: "refs/tags/second".into(),
1483 expected_old: None,
1484 force: false,
1485 },
1486 PushCommand {
1487 src: Some(first),
1488 dst: "refs/tags/first".into(),
1489 expected_old: None,
1490 force: false,
1491 },
1492 ],
1493 default_options(),
1494 );
1495
1496 assert_eq!(plan.pack_objects, vec![second, first]);
1497 }
1498
1499 fn push_local_actions(
1500 local: &Path,
1501 remote: &Path,
1502 plan: &PushActionPlan,
1503 ) -> Result<PushOutcome> {
1504 let destination = PushDestination::Local {
1505 git_dir: remote.to_path_buf(),
1506 common_git_dir: remote.to_path_buf(),
1507 };
1508 let config = GitConfig::default();
1509 let mut credentials = NoCredentials;
1510 let mut progress = SilentProgress;
1511 push_actions(
1512 PushActionRequest {
1513 git_dir: local,
1514 common_git_dir: local,
1515 format: ObjectFormat::Sha1,
1516 config: &config,
1517 remote: "origin",
1518 destination: &destination,
1519 plan,
1520 },
1521 PushServices {
1522 credentials: &mut credentials,
1523 progress: &mut progress,
1524 },
1525 )
1526 }
1527
1528 #[test]
1529 fn local_push_returns_success_report_status_and_updates_ref() {
1530 let local = temp_repo("local-success");
1531 let remote = temp_repo("remote-success");
1532 let base = write_commit(&local, Vec::new(), "base");
1533 let tip = write_commit(&local, vec![base], "tip");
1534 set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
1535 set_ref(
1536 &local,
1537 "HEAD",
1538 RefTarget::Symbolic("refs/heads/main".into()),
1539 );
1540 let destination = PushDestination::Local {
1541 git_dir: remote.clone(),
1542 common_git_dir: remote.clone(),
1543 };
1544 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
1545 let options = default_options();
1546 let request = PushRequest {
1547 git_dir: &local,
1548 common_git_dir: &local,
1549 format: ObjectFormat::Sha1,
1550 config: &GitConfig::default(),
1551 remote: "origin",
1552 destination: &destination,
1553 refspecs: &refspecs,
1554 options: &options,
1555 };
1556 let mut credentials = NoCredentials;
1557 let mut progress = SilentProgress;
1558
1559 let outcome = push(
1560 request,
1561 PushServices {
1562 credentials: &mut credentials,
1563 progress: &mut progress,
1564 },
1565 )
1566 .expect("push should succeed");
1567
1568 assert_eq!(outcome.commands.len(), 1);
1569 let report = outcome.report.expect("local receive-pack reports status");
1570 assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
1571 assert!(matches!(
1572 report.commands.as_slice(),
1573 [ReceivePackCommandStatus::Ok { name }] if name == "refs/heads/main"
1574 ));
1575 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1576 assert_eq!(
1577 remote_refs
1578 .read_ref("refs/heads/main")
1579 .expect("remote ref should read"),
1580 Some(RefTarget::Direct(tip))
1581 );
1582 }
1583
1584 #[test]
1585 fn local_push_actions_preserves_exact_old_new_update() {
1586 let local = temp_repo("actions-update-local");
1587 let remote = temp_repo("actions-update-remote");
1588 let base = write_commit(&local, Vec::new(), "base");
1589 let remote_base = write_commit(&remote, Vec::new(), "base");
1590 assert_eq!(remote_base, base);
1591 let tip = write_commit(&local, vec![base], "tip");
1592 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1593 let plan = PushActionPlan::from_actions(
1594 vec![PushAction::Update {
1595 dst: "refs/heads/main".into(),
1596 old: base,
1597 new: tip,
1598 }],
1599 default_options(),
1600 );
1601
1602 let outcome = push_local_actions(&local, &remote, &plan).expect("push actions");
1603
1604 assert_eq!(outcome.commands.len(), 1);
1605 assert_eq!(outcome.commands[0].old_id, base);
1606 assert_eq!(outcome.commands[0].new_id, tip);
1607 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1608 assert_eq!(
1609 remote_refs
1610 .read_ref("refs/heads/main")
1611 .expect("remote ref should read"),
1612 Some(RefTarget::Direct(tip))
1613 );
1614 }
1615
1616 #[test]
1617 fn local_push_actions_honors_per_command_force() {
1618 let local = temp_repo("actions-command-force-local");
1619 let remote = temp_repo("actions-command-force-remote");
1620 let base = write_commit(&local, Vec::new(), "base");
1621 let remote_base = write_commit(&remote, Vec::new(), "base");
1622 assert_eq!(remote_base, base);
1623 let unrelated = write_commit(&local, Vec::new(), "unrelated");
1624 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1625
1626 let unforced = PushActionPlan::from_commands(
1627 vec![PushCommand {
1628 src: Some(unrelated),
1629 dst: "refs/heads/main".into(),
1630 expected_old: Some(base),
1631 force: false,
1632 }],
1633 default_options(),
1634 );
1635 let err = push_local_actions(&local, &remote, &unforced)
1636 .expect_err("non-fast-forward should reject without command force");
1637 assert!(err.to_string().contains("non-fast-forward"));
1638
1639 let forced = PushActionPlan::from_commands(
1640 vec![PushCommand {
1641 src: Some(unrelated),
1642 dst: "refs/heads/main".into(),
1643 expected_old: Some(base),
1644 force: true,
1645 }],
1646 default_options(),
1647 );
1648 let outcome = push_local_actions(&local, &remote, &forced).expect("command force pushes");
1649
1650 assert_eq!(outcome.commands.len(), 1);
1651 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1652 assert_eq!(
1653 remote_refs
1654 .read_ref("refs/heads/main")
1655 .expect("remote ref should read"),
1656 Some(RefTarget::Direct(unrelated))
1657 );
1658 }
1659
1660 #[test]
1661 fn local_push_actions_command_force_is_precise_for_non_ff_validation() {
1662 let local = temp_repo("actions-command-force-precise-local");
1663 let remote = temp_repo("actions-command-force-precise-remote");
1664 let base = write_commit(&local, Vec::new(), "base");
1665 let remote_base = write_commit(&remote, Vec::new(), "base");
1666 assert_eq!(remote_base, base);
1667 let forced_unrelated = write_commit(&local, Vec::new(), "forced unrelated");
1668 let unforced_unrelated = write_commit(&local, Vec::new(), "unforced unrelated");
1669 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1670 set_ref(&remote, "refs/heads/topic", RefTarget::Direct(base));
1671 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1672 vec![
1673 PushCommand {
1674 src: Some(forced_unrelated),
1675 dst: "refs/heads/main".into(),
1676 expected_old: Some(base),
1677 force: true,
1678 },
1679 PushCommand {
1680 src: Some(unforced_unrelated),
1681 dst: "refs/heads/topic".into(),
1682 expected_old: Some(base),
1683 force: false,
1684 },
1685 ],
1686 default_options(),
1687 );
1688
1689 let err = push_local_actions(&local, &remote, &plan)
1690 .expect_err("only the forced command should bypass non-fast-forward validation");
1691
1692 assert!(err.to_string().contains("non-fast-forward update to topic"));
1693 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1694 assert_eq!(
1695 remote_refs
1696 .read_ref("refs/heads/main")
1697 .expect("remote ref should read"),
1698 Some(RefTarget::Direct(base))
1699 );
1700 assert_eq!(
1701 remote_refs
1702 .read_ref("refs/heads/topic")
1703 .expect("remote ref should read"),
1704 Some(RefTarget::Direct(base))
1705 );
1706 }
1707
1708 #[test]
1709 fn local_push_actions_stale_update_old_rejects_without_mutating() {
1710 let local = temp_repo("actions-stale-local");
1711 let remote = temp_repo("actions-stale-remote");
1712 let base = write_commit(&local, Vec::new(), "base");
1713 let remote_base = write_commit(&remote, Vec::new(), "base");
1714 assert_eq!(remote_base, base);
1715 let tip = write_commit(&local, vec![base], "tip");
1716 let concurrent = write_commit(&remote, vec![base], "concurrent");
1717 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
1718 let plan = PushActionPlan::from_actions(
1719 vec![PushAction::Update {
1720 dst: "refs/heads/main".into(),
1721 old: base,
1722 new: tip,
1723 }],
1724 default_options(),
1725 );
1726
1727 let err = push_local_actions(&local, &remote, &plan).expect_err("stale old rejects");
1728
1729 assert!(err.to_string().contains("expected ref refs/heads/main"));
1730 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1731 assert_eq!(
1732 remote_refs
1733 .read_ref("refs/heads/main")
1734 .expect("remote ref should read"),
1735 Some(RefTarget::Direct(concurrent))
1736 );
1737 }
1738
1739 #[test]
1740 fn local_push_actions_stale_delete_old_rejects_without_mutating() {
1741 let local = temp_repo("actions-delete-local");
1742 let remote = temp_repo("actions-delete-remote");
1743 let base = write_commit(&local, Vec::new(), "base");
1744 let remote_base = write_commit(&remote, Vec::new(), "base");
1745 assert_eq!(remote_base, base);
1746 let concurrent = write_commit(&remote, vec![base], "concurrent");
1747 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
1748 let plan = PushActionPlan::from_actions(
1749 vec![PushAction::Delete {
1750 dst: "refs/heads/main".into(),
1751 old: Some(base),
1752 }],
1753 default_options(),
1754 );
1755
1756 let err = push_local_actions(&local, &remote, &plan).expect_err("stale delete rejects");
1757
1758 assert!(err.to_string().contains("expected ref refs/heads/main"));
1759 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1760 assert_eq!(
1761 remote_refs
1762 .read_ref("refs/heads/main")
1763 .expect("remote ref should read"),
1764 Some(RefTarget::Direct(concurrent))
1765 );
1766 }
1767
1768 #[test]
1769 fn local_push_actions_create_rejects_existing_ref() {
1770 let local = temp_repo("actions-create-local");
1771 let remote = temp_repo("actions-create-remote");
1772 let base = write_commit(&local, Vec::new(), "base");
1773 let remote_base = write_commit(&remote, Vec::new(), "base");
1774 assert_eq!(remote_base, base);
1775 let tip = write_commit(&local, vec![base], "tip");
1776 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1777 let plan = PushActionPlan::from_actions(
1778 vec![PushAction::Create {
1779 dst: "refs/heads/main".into(),
1780 new: tip,
1781 }],
1782 default_options(),
1783 );
1784
1785 let err = push_local_actions(&local, &remote, &plan).expect_err("create must be absent");
1786
1787 assert!(
1788 err.to_string()
1789 .contains("expected ref refs/heads/main to not already exist")
1790 );
1791 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1792 assert_eq!(
1793 remote_refs
1794 .read_ref("refs/heads/main")
1795 .expect("remote ref should read"),
1796 Some(RefTarget::Direct(base))
1797 );
1798 }
1799
1800 #[test]
1801 fn report_status_rejection_is_an_error() {
1802 let report = ReceivePackReportStatus {
1803 unpack: ReceivePackUnpackStatus::Ok,
1804 commands: vec![ReceivePackCommandStatus::Ng {
1805 name: "refs/heads/main".into(),
1806 message: "hook declined".into(),
1807 }],
1808 };
1809
1810 let err = validate_receive_pack_report(&report).expect_err("ng report should fail");
1811
1812 assert!(err.to_string().contains("hook declined"));
1813 }
1814
1815 #[test]
1816 fn failed_local_push_does_not_partially_mutate_remote_ref() {
1817 let local = temp_repo("local-rejected");
1818 let remote = temp_repo("remote-rejected");
1819 let base = write_commit(&local, Vec::new(), "base");
1820 let planned = write_commit(&local, vec![base], "planned");
1821 let concurrent = write_commit(&local, vec![base], "concurrent");
1822 set_ref(&local, "refs/heads/main", RefTarget::Direct(planned));
1823 set_ref(
1824 &local,
1825 "HEAD",
1826 RefTarget::Symbolic("refs/heads/main".into()),
1827 );
1828 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1829 let destination = PushDestination::Local {
1830 git_dir: remote.clone(),
1831 common_git_dir: remote.clone(),
1832 };
1833 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
1834 let options = default_options();
1835 let request = PushRequest {
1836 git_dir: &local,
1837 common_git_dir: &local,
1838 format: ObjectFormat::Sha1,
1839 config: &GitConfig::default(),
1840 remote: "origin",
1841 destination: &destination,
1842 refspecs: &refspecs,
1843 options: &options,
1844 };
1845 let mut credentials = NoCredentials;
1846 let mut progress = SilentProgress;
1847 let mut services = PushServices {
1848 credentials: &mut credentials,
1849 progress: &mut progress,
1850 };
1851 let plan = plan_push(request, &mut services).expect("push should plan");
1852
1853 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
1854 let _err = execute_push_plan(request, &mut services, plan)
1855 .expect_err("stale old id should reject the ref update");
1856
1857 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1858 assert_eq!(
1859 remote_refs
1860 .read_ref("refs/heads/main")
1861 .expect("remote ref should read"),
1862 Some(RefTarget::Direct(concurrent))
1863 );
1864 }
1865}