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| parse_refspec(&normalize_push_refspec_for_sources(refspec, local_refs)))
886 .collect::<Result<Vec<_>>>()?;
887 let mut command_forces = Vec::new();
888 for refspec in &parsed_refspecs {
889 for command in plan_push_commands(
890 format,
891 local_refs,
892 remote_refs,
893 std::slice::from_ref(refspec),
894 )? {
895 command_forces.push((command, force || refspec.force));
896 }
897 }
898 Ok(command_forces)
899}
900
901fn add_revision_push_sources(
902 git_dir: &Path,
903 format: ObjectFormat,
904 refspecs: &[String],
905 local_refs: &mut Vec<PushSourceRef>,
906) {
907 for refspec in refspecs {
908 let refspec = refspec.strip_prefix('+').unwrap_or(refspec);
909 let src = refspec.split_once(':').map_or(refspec, |(src, _)| src);
910 if src.is_empty() || src == "HEAD" || src.starts_with("refs/") {
911 continue;
912 }
913 if local_refs.iter().any(|reference| {
914 reference.name == src
915 || reference.name == format!("refs/heads/{src}")
916 || reference.name == format!("refs/tags/{src}")
917 }) {
918 continue;
919 }
920 if let Ok(oid) = sley_rev::resolve_revision(git_dir, format, src)
921 && !local_refs.iter().any(|reference| reference.name == src)
922 {
923 local_refs.push(PushSourceRef {
924 name: src.to_string(),
925 oid,
926 });
927 }
928 }
929}
930
931fn normalize_push_refspec_for_sources(refspec: &str, local_refs: &[PushSourceRef]) -> String {
932 let (force, refspec) = refspec
933 .strip_prefix('+')
934 .map_or((false, refspec), |refspec| (true, refspec));
935 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
936 let (src, src_kind) = normalize_push_source_refname(src, local_refs);
937 let dst = normalize_push_destination_refname(dst, src_kind);
938 format!("{src}:{dst}")
939 } else {
940 let (name, _) = normalize_push_source_refname(refspec, local_refs);
941 format!("{name}:{name}")
942 };
943 if force {
944 format!("+{normalized}")
945 } else {
946 normalized
947 }
948}
949
950#[derive(Clone, Copy)]
951enum PushSourceKind {
952 Branch,
953 Tag,
954 Other,
955}
956
957fn normalize_push_source_refname(
958 name: &str,
959 local_refs: &[PushSourceRef],
960) -> (String, PushSourceKind) {
961 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
962 return (name.to_string(), PushSourceKind::Other);
963 }
964 let branch = format!("refs/heads/{name}");
965 let tag = format!("refs/tags/{name}");
966 let has_branch = local_refs.iter().any(|reference| reference.name == branch);
967 let has_tag = local_refs.iter().any(|reference| reference.name == tag);
968 if has_tag && !has_branch {
969 (tag, PushSourceKind::Tag)
970 } else if has_branch {
971 (branch, PushSourceKind::Branch)
972 } else if local_refs.iter().any(|reference| reference.name == name) {
973 (name.to_string(), PushSourceKind::Other)
974 } else {
975 (branch, PushSourceKind::Branch)
976 }
977}
978
979fn normalize_push_destination_refname(name: &str, src_kind: PushSourceKind) -> String {
980 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
981 return name.to_string();
982 }
983 match src_kind {
984 PushSourceKind::Tag => format!("refs/tags/{name}"),
985 PushSourceKind::Branch | PushSourceKind::Other => format!("refs/heads/{name}"),
986 }
987}
988
989fn commands_from_forces(command_forces: &[(ReceivePackCommand, bool)]) -> Vec<ReceivePackCommand> {
991 command_forces
992 .iter()
993 .map(|(command, _)| command.clone())
994 .collect()
995}
996
997fn receive_pack_commands_from_action_plan(
998 format: ObjectFormat,
999 plan: &PushActionPlan,
1000) -> Result<Vec<ReceivePackCommand>> {
1001 let zero = ObjectId::null(format);
1002 for oid in &plan.pack_objects {
1003 if oid.format() != format {
1004 return Err(GitError::InvalidObjectId(format!(
1005 "push pack object {oid} has {} object id for {} repository",
1006 oid.format().name(),
1007 format.name()
1008 )));
1009 }
1010 }
1011 plan.commands
1012 .iter()
1013 .map(|command| {
1014 let old_id = command.expected_old.unwrap_or(zero);
1015 let new_id = command.src.unwrap_or(zero);
1016 if old_id.format() != format {
1017 return Err(GitError::InvalidObjectId(format!(
1018 "push command {} expected old has {} object id for {} repository",
1019 command.dst,
1020 old_id.format().name(),
1021 format.name()
1022 )));
1023 }
1024 if new_id.format() != format {
1025 return Err(GitError::InvalidObjectId(format!(
1026 "push command {} new id has {} object id for {} repository",
1027 command.dst,
1028 new_id.format().name(),
1029 format.name()
1030 )));
1031 }
1032 Ok(ReceivePackCommand {
1033 old_id,
1034 new_id,
1035 name: command.dst.clone(),
1036 })
1037 })
1038 .collect()
1039}
1040
1041pub fn validate_receive_pack_report(report: &ReceivePackReportStatus) -> Result<()> {
1044 if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1045 return Err(GitError::Command(format!(
1046 "failed to push some refs: unpack failed: {message}"
1047 )));
1048 }
1049 for status in &report.commands {
1050 if let ReceivePackCommandStatus::Ng { name, message } = status {
1051 return Err(GitError::Command(format!(
1052 "failed to push {name}: {message}"
1053 )));
1054 }
1055 }
1056 Ok(())
1057}
1058
1059pub fn local_push_source_refs(
1063 store: &FileRefStore,
1064 format: ObjectFormat,
1065) -> Result<Vec<PushSourceRef>> {
1066 let mut refs = Vec::new();
1067 for reference in store.list_refs()? {
1068 let Some((oid, _)) = resolve_for_each_ref_target(store, &reference)? else {
1069 continue;
1070 };
1071 if oid.format() != format {
1072 return Err(GitError::InvalidObjectId(format!(
1073 "local ref {} has {} object id for {} repository",
1074 reference.name,
1075 oid.format().name(),
1076 format.name()
1077 )));
1078 }
1079 refs.push(PushSourceRef {
1080 name: reference.name.clone(),
1081 oid,
1082 });
1083 if let Some(short) = reference.name.strip_prefix("refs/heads/") {
1084 refs.push(PushSourceRef {
1085 name: short.to_string(),
1086 oid,
1087 });
1088 }
1089 if let Some(short) = reference.name.strip_prefix("refs/tags/") {
1090 refs.push(PushSourceRef {
1091 name: short.to_string(),
1092 oid,
1093 });
1094 }
1095 }
1096 if let Some(target) = store.read_ref("HEAD")? {
1097 let head = Ref {
1098 name: "HEAD".to_string(),
1099 target,
1100 };
1101 if let Some((oid, _)) = resolve_for_each_ref_target(store, &head)?
1102 && oid.format() == format
1103 {
1104 refs.push(PushSourceRef {
1105 name: "HEAD".to_string(),
1106 oid,
1107 });
1108 }
1109 }
1110 Ok(refs)
1111}
1112
1113pub fn normalize_push_refspec(refspec: &str) -> String {
1117 let (force, refspec) = refspec
1118 .strip_prefix('+')
1119 .map_or((false, refspec), |refspec| (true, refspec));
1120 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1121 let src = normalize_push_refname(src);
1122 let dst = normalize_push_refname(dst);
1123 format!("{src}:{dst}")
1124 } else {
1125 let name = normalize_push_refname(refspec);
1126 format!("{name}:{name}")
1127 };
1128 if force {
1129 format!("+{normalized}")
1130 } else {
1131 normalized
1132 }
1133}
1134
1135pub fn normalize_push_refname(name: &str) -> String {
1138 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1139 name.to_string()
1140 } else {
1141 format!("refs/heads/{name}")
1142 }
1143}
1144
1145pub fn reject_non_fast_forward_pushes(
1149 local_db: &FileObjectDatabase,
1150 format: ObjectFormat,
1151 command_forces: &[(ReceivePackCommand, bool)],
1152) -> Result<()> {
1153 for (command, force) in command_forces {
1154 if *force
1155 || !command.name.starts_with("refs/heads/")
1156 || command.old_id.is_null()
1157 || command.new_id.is_null()
1158 {
1159 continue;
1160 }
1161 let ancestors = ancestor_depths(local_db, format, &command.new_id)?;
1162 if !ancestors.contains_key(&command.old_id) {
1163 let short = command.name.trim_start_matches("refs/heads/");
1164 return Err(GitError::Command(format!(
1165 "failed to push some refs: non-fast-forward update to {short}"
1166 )));
1167 }
1168 }
1169 Ok(())
1170}
1171
1172fn ancestor_depths(
1176 db: &FileObjectDatabase,
1177 format: ObjectFormat,
1178 start: &ObjectId,
1179) -> Result<HashMap<ObjectId, usize>> {
1180 let mut depths = HashMap::new();
1181 let mut pending = std::collections::VecDeque::from([(start.clone(), 0usize)]);
1182 while let Some((oid, depth)) = pending.pop_front() {
1183 if depths.get(&oid).is_some_and(|existing| *existing <= depth) {
1184 continue;
1185 }
1186 depths.insert(oid, depth);
1187 let object = db.read_object(&oid)?;
1188 if object.object_type != ObjectType::Commit {
1189 return Err(GitError::InvalidObject(format!(
1190 "expected commit {oid}, found {}",
1191 object.object_type.as_str()
1192 )));
1193 }
1194 let commit = Commit::parse_ref(format, &object.body)?;
1195 for parent in commit.parents {
1196 pending.push_back((parent, depth + 1));
1197 }
1198 }
1199 Ok(depths)
1200}
1201
1202fn resolve_for_each_ref_target(
1205 store: &FileRefStore,
1206 reference: &Ref,
1207) -> Result<Option<(ObjectId, Option<String>)>> {
1208 let mut target = reference.target.clone();
1209 let mut symref = None;
1210 for _ in 0..5 {
1211 match target {
1212 RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
1213 RefTarget::Symbolic(name) => {
1214 symref.get_or_insert_with(|| name.clone());
1215 let Some(next) = store.read_ref(&name)? else {
1216 return Ok(None);
1217 };
1218 target = next;
1219 }
1220 }
1221 }
1222 Ok(None)
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227 use super::*;
1228 use std::fs;
1229 use std::sync::atomic::{AtomicU64, Ordering};
1230
1231 use sley_formats::RepositoryLayout;
1232 use sley_object::{Commit, EncodedObject, ObjectType, Tree};
1233 use sley_odb::{FileObjectDatabase, ObjectWriter};
1234 use sley_protocol::{ReceivePackCommandStatus, ReceivePackUnpackStatus};
1235 use sley_refs::{RefTarget, RefUpdate};
1236
1237 use crate::{NoCredentials, SilentProgress};
1238
1239 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1240
1241 fn temp_repo(name: &str) -> PathBuf {
1242 let dir = std::env::temp_dir().join(format!(
1243 "sley-remote-push-{name}-{}-{}",
1244 std::process::id(),
1245 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1246 ));
1247 let _ = fs::remove_dir_all(&dir);
1248 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
1249 .expect("test repository should initialize");
1250 dir.join(".git")
1251 }
1252
1253 fn write_commit(git_dir: &Path, parents: Vec<ObjectId>, message: &str) -> ObjectId {
1254 let format = ObjectFormat::Sha1;
1255 let db = FileObjectDatabase::from_git_dir(git_dir, format);
1256 let tree = db
1257 .write_object(EncodedObject::new(
1258 ObjectType::Tree,
1259 Tree { entries: vec![] }.write(),
1260 ))
1261 .expect("tree should write");
1262 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
1263 db.write_object(EncodedObject::new(
1264 ObjectType::Commit,
1265 Commit {
1266 tree,
1267 parents,
1268 author: identity.clone(),
1269 committer: identity,
1270 encoding: None,
1271 message: format!("{message}\n").into_bytes(),
1272 }
1273 .write(),
1274 ))
1275 .expect("commit should write")
1276 }
1277
1278 fn set_ref(git_dir: &Path, name: &str, target: RefTarget) {
1279 let store = FileRefStore::new(git_dir, ObjectFormat::Sha1);
1280 let mut tx = store.transaction();
1281 tx.update(RefUpdate {
1282 name: name.to_string(),
1283 expected: None,
1284 new: target,
1285 reflog: None,
1286 });
1287 tx.commit().expect("ref should update");
1288 }
1289
1290 fn default_options() -> PushOptions {
1291 PushOptions {
1292 quiet: true,
1293 force: false,
1294 }
1295 }
1296
1297 #[test]
1298 fn push_action_plan_infers_pack_roots_from_non_delete_commands() {
1299 let repo = temp_repo("action-plan-infer-roots");
1300 let first = write_commit(&repo, Vec::new(), "first");
1301 let second = write_commit(&repo, vec![first], "second");
1302
1303 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1304 vec![
1305 PushCommand {
1306 src: Some(first),
1307 dst: "refs/heads/main".into(),
1308 expected_old: None,
1309 force: false,
1310 },
1311 PushCommand {
1312 src: Some(second),
1313 dst: "refs/heads/topic".into(),
1314 expected_old: Some(first),
1315 force: true,
1316 },
1317 ],
1318 default_options(),
1319 );
1320
1321 assert_eq!(plan.pack_objects, vec![first, second]);
1322 assert!(!plan.commands[0].force);
1323 assert!(plan.commands[1].force);
1324 }
1325
1326 #[test]
1327 fn push_action_plan_inferred_pack_roots_exclude_deletes() {
1328 let repo = temp_repo("action-plan-delete-roots");
1329 let old = write_commit(&repo, Vec::new(), "old");
1330 let new = write_commit(&repo, vec![old], "new");
1331
1332 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1333 vec![
1334 PushCommand {
1335 src: None,
1336 dst: "refs/heads/remove".into(),
1337 expected_old: Some(old),
1338 force: false,
1339 },
1340 PushCommand {
1341 src: Some(new),
1342 dst: "refs/heads/keep".into(),
1343 expected_old: Some(old),
1344 force: false,
1345 },
1346 ],
1347 default_options(),
1348 );
1349
1350 assert_eq!(plan.pack_objects, vec![new]);
1351 }
1352
1353 #[test]
1354 fn push_action_plan_inferred_pack_roots_dedupe_first_seen_order() {
1355 let repo = temp_repo("action-plan-dedupe-roots");
1356 let first = write_commit(&repo, Vec::new(), "first");
1357 let second = write_commit(&repo, Vec::new(), "second");
1358
1359 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1360 vec![
1361 PushCommand {
1362 src: Some(second),
1363 dst: "refs/heads/second".into(),
1364 expected_old: None,
1365 force: false,
1366 },
1367 PushCommand {
1368 src: Some(first),
1369 dst: "refs/heads/first".into(),
1370 expected_old: None,
1371 force: false,
1372 },
1373 PushCommand {
1374 src: Some(second),
1375 dst: "refs/tags/second".into(),
1376 expected_old: None,
1377 force: false,
1378 },
1379 PushCommand {
1380 src: Some(first),
1381 dst: "refs/tags/first".into(),
1382 expected_old: None,
1383 force: false,
1384 },
1385 ],
1386 default_options(),
1387 );
1388
1389 assert_eq!(plan.pack_objects, vec![second, first]);
1390 }
1391
1392 fn push_local_actions(
1393 local: &Path,
1394 remote: &Path,
1395 plan: &PushActionPlan,
1396 ) -> Result<PushOutcome> {
1397 let destination = PushDestination::Local {
1398 git_dir: remote.to_path_buf(),
1399 common_git_dir: remote.to_path_buf(),
1400 };
1401 let config = GitConfig::default();
1402 let mut credentials = NoCredentials;
1403 let mut progress = SilentProgress;
1404 push_actions(
1405 PushActionRequest {
1406 git_dir: local,
1407 common_git_dir: local,
1408 format: ObjectFormat::Sha1,
1409 config: &config,
1410 remote: "origin",
1411 destination: &destination,
1412 plan,
1413 },
1414 PushServices {
1415 credentials: &mut credentials,
1416 progress: &mut progress,
1417 },
1418 )
1419 }
1420
1421 #[test]
1422 fn local_push_returns_success_report_status_and_updates_ref() {
1423 let local = temp_repo("local-success");
1424 let remote = temp_repo("remote-success");
1425 let base = write_commit(&local, Vec::new(), "base");
1426 let tip = write_commit(&local, vec![base], "tip");
1427 set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
1428 set_ref(
1429 &local,
1430 "HEAD",
1431 RefTarget::Symbolic("refs/heads/main".into()),
1432 );
1433 let destination = PushDestination::Local {
1434 git_dir: remote.clone(),
1435 common_git_dir: remote.clone(),
1436 };
1437 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
1438 let options = default_options();
1439 let request = PushRequest {
1440 git_dir: &local,
1441 common_git_dir: &local,
1442 format: ObjectFormat::Sha1,
1443 config: &GitConfig::default(),
1444 remote: "origin",
1445 destination: &destination,
1446 refspecs: &refspecs,
1447 options: &options,
1448 };
1449 let mut credentials = NoCredentials;
1450 let mut progress = SilentProgress;
1451
1452 let outcome = push(
1453 request,
1454 PushServices {
1455 credentials: &mut credentials,
1456 progress: &mut progress,
1457 },
1458 )
1459 .expect("push should succeed");
1460
1461 assert_eq!(outcome.commands.len(), 1);
1462 let report = outcome.report.expect("local receive-pack reports status");
1463 assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
1464 assert!(matches!(
1465 report.commands.as_slice(),
1466 [ReceivePackCommandStatus::Ok { name }] if name == "refs/heads/main"
1467 ));
1468 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1469 assert_eq!(
1470 remote_refs
1471 .read_ref("refs/heads/main")
1472 .expect("remote ref should read"),
1473 Some(RefTarget::Direct(tip))
1474 );
1475 }
1476
1477 #[test]
1478 fn local_push_actions_preserves_exact_old_new_update() {
1479 let local = temp_repo("actions-update-local");
1480 let remote = temp_repo("actions-update-remote");
1481 let base = write_commit(&local, Vec::new(), "base");
1482 let remote_base = write_commit(&remote, Vec::new(), "base");
1483 assert_eq!(remote_base, base);
1484 let tip = write_commit(&local, vec![base], "tip");
1485 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1486 let plan = PushActionPlan::from_actions(
1487 vec![PushAction::Update {
1488 dst: "refs/heads/main".into(),
1489 old: base,
1490 new: tip,
1491 }],
1492 default_options(),
1493 );
1494
1495 let outcome = push_local_actions(&local, &remote, &plan).expect("push actions");
1496
1497 assert_eq!(outcome.commands.len(), 1);
1498 assert_eq!(outcome.commands[0].old_id, base);
1499 assert_eq!(outcome.commands[0].new_id, tip);
1500 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1501 assert_eq!(
1502 remote_refs
1503 .read_ref("refs/heads/main")
1504 .expect("remote ref should read"),
1505 Some(RefTarget::Direct(tip))
1506 );
1507 }
1508
1509 #[test]
1510 fn local_push_actions_honors_per_command_force() {
1511 let local = temp_repo("actions-command-force-local");
1512 let remote = temp_repo("actions-command-force-remote");
1513 let base = write_commit(&local, Vec::new(), "base");
1514 let remote_base = write_commit(&remote, Vec::new(), "base");
1515 assert_eq!(remote_base, base);
1516 let unrelated = write_commit(&local, Vec::new(), "unrelated");
1517 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1518
1519 let unforced = PushActionPlan::from_commands(
1520 vec![PushCommand {
1521 src: Some(unrelated),
1522 dst: "refs/heads/main".into(),
1523 expected_old: Some(base),
1524 force: false,
1525 }],
1526 default_options(),
1527 );
1528 let err = push_local_actions(&local, &remote, &unforced)
1529 .expect_err("non-fast-forward should reject without command force");
1530 assert!(err.to_string().contains("non-fast-forward"));
1531
1532 let forced = PushActionPlan::from_commands(
1533 vec![PushCommand {
1534 src: Some(unrelated),
1535 dst: "refs/heads/main".into(),
1536 expected_old: Some(base),
1537 force: true,
1538 }],
1539 default_options(),
1540 );
1541 let outcome = push_local_actions(&local, &remote, &forced).expect("command force pushes");
1542
1543 assert_eq!(outcome.commands.len(), 1);
1544 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1545 assert_eq!(
1546 remote_refs
1547 .read_ref("refs/heads/main")
1548 .expect("remote ref should read"),
1549 Some(RefTarget::Direct(unrelated))
1550 );
1551 }
1552
1553 #[test]
1554 fn local_push_actions_command_force_is_precise_for_non_ff_validation() {
1555 let local = temp_repo("actions-command-force-precise-local");
1556 let remote = temp_repo("actions-command-force-precise-remote");
1557 let base = write_commit(&local, Vec::new(), "base");
1558 let remote_base = write_commit(&remote, Vec::new(), "base");
1559 assert_eq!(remote_base, base);
1560 let forced_unrelated = write_commit(&local, Vec::new(), "forced unrelated");
1561 let unforced_unrelated = write_commit(&local, Vec::new(), "unforced unrelated");
1562 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1563 set_ref(&remote, "refs/heads/topic", RefTarget::Direct(base));
1564 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1565 vec![
1566 PushCommand {
1567 src: Some(forced_unrelated),
1568 dst: "refs/heads/main".into(),
1569 expected_old: Some(base),
1570 force: true,
1571 },
1572 PushCommand {
1573 src: Some(unforced_unrelated),
1574 dst: "refs/heads/topic".into(),
1575 expected_old: Some(base),
1576 force: false,
1577 },
1578 ],
1579 default_options(),
1580 );
1581
1582 let err = push_local_actions(&local, &remote, &plan)
1583 .expect_err("only the forced command should bypass non-fast-forward validation");
1584
1585 assert!(err.to_string().contains("non-fast-forward update to topic"));
1586 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1587 assert_eq!(
1588 remote_refs
1589 .read_ref("refs/heads/main")
1590 .expect("remote ref should read"),
1591 Some(RefTarget::Direct(base))
1592 );
1593 assert_eq!(
1594 remote_refs
1595 .read_ref("refs/heads/topic")
1596 .expect("remote ref should read"),
1597 Some(RefTarget::Direct(base))
1598 );
1599 }
1600
1601 #[test]
1602 fn local_push_actions_stale_update_old_rejects_without_mutating() {
1603 let local = temp_repo("actions-stale-local");
1604 let remote = temp_repo("actions-stale-remote");
1605 let base = write_commit(&local, Vec::new(), "base");
1606 let remote_base = write_commit(&remote, Vec::new(), "base");
1607 assert_eq!(remote_base, base);
1608 let tip = write_commit(&local, vec![base], "tip");
1609 let concurrent = write_commit(&remote, vec![base], "concurrent");
1610 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
1611 let plan = PushActionPlan::from_actions(
1612 vec![PushAction::Update {
1613 dst: "refs/heads/main".into(),
1614 old: base,
1615 new: tip,
1616 }],
1617 default_options(),
1618 );
1619
1620 let err = push_local_actions(&local, &remote, &plan).expect_err("stale old rejects");
1621
1622 assert!(err.to_string().contains("expected ref refs/heads/main"));
1623 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1624 assert_eq!(
1625 remote_refs
1626 .read_ref("refs/heads/main")
1627 .expect("remote ref should read"),
1628 Some(RefTarget::Direct(concurrent))
1629 );
1630 }
1631
1632 #[test]
1633 fn local_push_actions_stale_delete_old_rejects_without_mutating() {
1634 let local = temp_repo("actions-delete-local");
1635 let remote = temp_repo("actions-delete-remote");
1636 let base = write_commit(&local, Vec::new(), "base");
1637 let remote_base = write_commit(&remote, Vec::new(), "base");
1638 assert_eq!(remote_base, base);
1639 let concurrent = write_commit(&remote, vec![base], "concurrent");
1640 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
1641 let plan = PushActionPlan::from_actions(
1642 vec![PushAction::Delete {
1643 dst: "refs/heads/main".into(),
1644 old: Some(base),
1645 }],
1646 default_options(),
1647 );
1648
1649 let err = push_local_actions(&local, &remote, &plan).expect_err("stale delete rejects");
1650
1651 assert!(err.to_string().contains("expected ref refs/heads/main"));
1652 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1653 assert_eq!(
1654 remote_refs
1655 .read_ref("refs/heads/main")
1656 .expect("remote ref should read"),
1657 Some(RefTarget::Direct(concurrent))
1658 );
1659 }
1660
1661 #[test]
1662 fn local_push_actions_create_rejects_existing_ref() {
1663 let local = temp_repo("actions-create-local");
1664 let remote = temp_repo("actions-create-remote");
1665 let base = write_commit(&local, Vec::new(), "base");
1666 let remote_base = write_commit(&remote, Vec::new(), "base");
1667 assert_eq!(remote_base, base);
1668 let tip = write_commit(&local, vec![base], "tip");
1669 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1670 let plan = PushActionPlan::from_actions(
1671 vec![PushAction::Create {
1672 dst: "refs/heads/main".into(),
1673 new: tip,
1674 }],
1675 default_options(),
1676 );
1677
1678 let err = push_local_actions(&local, &remote, &plan).expect_err("create must be absent");
1679
1680 assert!(
1681 err.to_string()
1682 .contains("expected ref refs/heads/main to not already exist")
1683 );
1684 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1685 assert_eq!(
1686 remote_refs
1687 .read_ref("refs/heads/main")
1688 .expect("remote ref should read"),
1689 Some(RefTarget::Direct(base))
1690 );
1691 }
1692
1693 #[test]
1694 fn report_status_rejection_is_an_error() {
1695 let report = ReceivePackReportStatus {
1696 unpack: ReceivePackUnpackStatus::Ok,
1697 commands: vec![ReceivePackCommandStatus::Ng {
1698 name: "refs/heads/main".into(),
1699 message: "hook declined".into(),
1700 }],
1701 };
1702
1703 let err = validate_receive_pack_report(&report).expect_err("ng report should fail");
1704
1705 assert!(err.to_string().contains("hook declined"));
1706 }
1707
1708 #[test]
1709 fn failed_local_push_does_not_partially_mutate_remote_ref() {
1710 let local = temp_repo("local-rejected");
1711 let remote = temp_repo("remote-rejected");
1712 let base = write_commit(&local, Vec::new(), "base");
1713 let planned = write_commit(&local, vec![base], "planned");
1714 let concurrent = write_commit(&local, vec![base], "concurrent");
1715 set_ref(&local, "refs/heads/main", RefTarget::Direct(planned));
1716 set_ref(
1717 &local,
1718 "HEAD",
1719 RefTarget::Symbolic("refs/heads/main".into()),
1720 );
1721 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1722 let destination = PushDestination::Local {
1723 git_dir: remote.clone(),
1724 common_git_dir: remote.clone(),
1725 };
1726 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
1727 let options = default_options();
1728 let request = PushRequest {
1729 git_dir: &local,
1730 common_git_dir: &local,
1731 format: ObjectFormat::Sha1,
1732 config: &GitConfig::default(),
1733 remote: "origin",
1734 destination: &destination,
1735 refspecs: &refspecs,
1736 options: &options,
1737 };
1738 let mut credentials = NoCredentials;
1739 let mut progress = SilentProgress;
1740 let mut services = PushServices {
1741 credentials: &mut credentials,
1742 progress: &mut progress,
1743 };
1744 let plan = plan_push(request, &mut services).expect("push should plan");
1745
1746 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
1747 let _err = execute_push_plan(request, &mut services, plan)
1748 .expect_err("stale old id should reject the ref update");
1749
1750 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1751 assert_eq!(
1752 remote_refs
1753 .read_ref("refs/heads/main")
1754 .expect("remote ref should read"),
1755 Some(RefTarget::Direct(concurrent))
1756 );
1757 }
1758}