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, RefSpec,
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(Debug, Clone, PartialEq, Eq)]
221pub enum PushRefStatus {
222 Ok,
224 UpToDate,
226 RejectNonFastForward,
228 RejectStale,
230 RemoteReject(String),
232 AtomicPushFailed,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct PushReportRef {
242 pub src: Option<String>,
245 pub dst: String,
247 pub old_id: ObjectId,
249 pub new_id: ObjectId,
251 pub forced: bool,
253 pub status: PushRefStatus,
255}
256
257impl PushReportRef {
258 pub fn is_deletion(&self) -> bool {
260 self.new_id.is_null()
261 }
262
263 pub fn had_error(&self) -> bool {
266 !matches!(self.status, PushRefStatus::Ok | PushRefStatus::UpToDate)
267 }
268}
269
270#[derive(Debug, Clone, Default, PartialEq, Eq)]
274pub struct PushStatusReport {
275 pub refs: Vec<PushReportRef>,
277}
278
279impl PushStatusReport {
280 pub fn had_errors(&self) -> bool {
282 self.refs.iter().any(PushReportRef::had_error)
283 }
284
285 pub fn refs_pushed(&self) -> bool {
288 self.refs.iter().any(|reference| {
289 reference.old_id != reference.new_id
290 && matches!(reference.status, PushRefStatus::Ok)
291 })
292 }
293}
294
295#[derive(Clone, Copy)]
297pub struct PushRequest<'a> {
298 pub git_dir: &'a Path,
300 pub common_git_dir: &'a Path,
302 pub format: ObjectFormat,
304 pub config: &'a GitConfig,
306 pub remote: &'a str,
308 pub destination: &'a PushDestination,
310 pub refspecs: &'a [String],
312 pub options: &'a PushOptions,
314}
315
316#[derive(Clone, Copy)]
318pub struct PushActionRequest<'a> {
319 pub git_dir: &'a Path,
321 pub common_git_dir: &'a Path,
323 pub format: ObjectFormat,
325 pub config: &'a GitConfig,
327 pub remote: &'a str,
329 pub destination: &'a PushDestination,
331 pub plan: &'a PushActionPlan,
333}
334
335pub struct PushServices<'a> {
337 pub credentials: &'a mut dyn CredentialProvider,
339 pub progress: &'a mut dyn ProgressSink,
341}
342
343pub struct PushPlan {
346 pub commands: Vec<ReceivePackCommand>,
348 execution: PushExecution,
349}
350
351enum PushExecution {
352 Noop,
353 #[cfg(feature = "http")]
354 Http {
355 remote_url: RemoteUrl,
356 features: ReceivePackFeatures,
357 advertisements: Vec<RefAdvertisement>,
358 pack_objects: Vec<ObjectId>,
359 },
360 Ssh(crate::ssh::SshPushPlan),
361 Git(crate::git::GitPushPlan),
362 Local {
363 remote_git_dir: PathBuf,
364 remote_common_git_dir: PathBuf,
365 remote_refs: Vec<RefAdvertisement>,
366 command_forces: Vec<(ReceivePackCommand, bool)>,
367 pack_objects: Vec<ObjectId>,
368 },
369}
370
371pub fn push(request: PushRequest<'_>, mut services: PushServices<'_>) -> Result<PushOutcome> {
386 let plan = plan_push(request, &mut services)?;
387 execute_push_plan(request, &mut services, plan)
388}
389
390pub fn push_actions(
392 request: PushActionRequest<'_>,
393 mut services: PushServices<'_>,
394) -> Result<PushOutcome> {
395 let plan = plan_push_actions(request, &mut services)?;
396 execute_push_action_plan(request, &mut services, plan)
397}
398
399pub fn plan_push(request: PushRequest<'_>, services: &mut PushServices<'_>) -> Result<PushPlan> {
402 let _ = request.config;
407 let _ = &mut services.progress;
408 match request.destination {
409 #[cfg(feature = "http")]
410 PushDestination::Http(remote_url) => plan_push_http(PushHttpRequest {
411 git_dir: request.git_dir,
412 common_git_dir: request.common_git_dir,
413 format: request.format,
414 remote_url,
415 refspecs: request.refspecs,
416 options: request.options,
417 credentials: services.credentials,
418 }),
419 #[cfg(not(feature = "http"))]
420 PushDestination::Http(_) => Err(GitError::Unsupported(
421 "HTTP transport is not enabled in this build".into(),
422 )),
423 PushDestination::Ssh(remote_url) => {
424 let plan = crate::ssh::plan_push_ssh(crate::ssh::SshPushRequest {
425 git_dir: request.git_dir,
426 common_git_dir: request.common_git_dir,
427 format: request.format,
428 remote: remote_url,
429 refspecs: request.refspecs,
430 force: request.options.force,
431 })?;
432 let commands = plan.commands.clone();
433 let execution = if commands.is_empty() {
434 PushExecution::Noop
435 } else {
436 PushExecution::Ssh(plan)
437 };
438 Ok(PushPlan {
439 commands,
440 execution,
441 })
442 }
443 PushDestination::Git(remote_url) => {
444 let plan = crate::git::plan_push_git(crate::git::GitPushRequest {
445 git_dir: request.git_dir,
446 common_git_dir: request.common_git_dir,
447 format: request.format,
448 remote: remote_url,
449 refspecs: request.refspecs,
450 force: request.options.force,
451 })?;
452 let commands = plan.commands.clone();
453 let execution = if commands.is_empty() {
454 PushExecution::Noop
455 } else {
456 PushExecution::Git(plan)
457 };
458 Ok(PushPlan {
459 commands,
460 execution,
461 })
462 }
463 PushDestination::Local {
464 git_dir: remote_git_dir,
465 common_git_dir: remote_common_git_dir,
466 } => plan_push_local(PushLocalRequest {
467 git_dir: request.git_dir,
468 common_git_dir: request.common_git_dir,
469 format: request.format,
470 remote: request.remote,
471 remote_git_dir,
472 remote_common_git_dir,
473 refspecs: request.refspecs,
474 options: request.options,
475 }),
476 }
477}
478
479pub fn plan_push_actions(
482 request: PushActionRequest<'_>,
483 services: &mut PushServices<'_>,
484) -> Result<PushPlan> {
485 let _ = request.config;
486 let _ = &mut services.progress;
487 let commands = receive_pack_commands_from_action_plan(request.format, request.plan)?;
488 let command_forces = commands
489 .iter()
490 .cloned()
491 .zip(request.plan.commands.iter())
492 .map(|(command, planned)| (command, request.plan.options.force || planned.force))
493 .collect::<Vec<_>>();
494 match request.destination {
495 #[cfg(feature = "http")]
496 PushDestination::Http(remote_url) => {
497 let client = crate::http::new_http_client();
498 let discovered = crate::http::http_service_advertisements(
499 &client,
500 remote_url,
501 request.format,
502 GitService::ReceivePack,
503 services.credentials,
504 )?;
505 let advertisement_set = discovered.set;
506 let features = advertised_receive_pack_features(&advertisement_set.refs)?;
507 verify_remote_object_format(&features, request.format)?;
508 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
509 reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
510 let execution = if commands.is_empty() {
511 PushExecution::Noop
512 } else {
513 PushExecution::Http {
514 remote_url: remote_url.clone(),
515 features,
516 advertisements: advertisement_set.refs,
517 pack_objects: request.plan.pack_objects.clone(),
518 }
519 };
520 Ok(PushPlan {
521 commands,
522 execution,
523 })
524 }
525 #[cfg(not(feature = "http"))]
526 PushDestination::Http(_) => Err(GitError::Unsupported(
527 "HTTP transport is not enabled in this build".into(),
528 )),
529 PushDestination::Ssh(remote_url) => {
530 let plan = crate::ssh::plan_push_ssh_commands(crate::ssh::SshPushCommandsRequest {
531 common_git_dir: request.common_git_dir,
532 format: request.format,
533 remote: remote_url,
534 command_forces: command_forces.clone(),
535 pack_objects: request.plan.pack_objects.clone(),
536 })?;
537 let commands = plan.commands.clone();
538 let execution = if commands.is_empty() {
539 PushExecution::Noop
540 } else {
541 PushExecution::Ssh(plan)
542 };
543 Ok(PushPlan {
544 commands,
545 execution,
546 })
547 }
548 PushDestination::Git(remote_url) => {
549 let plan = crate::git::plan_push_git_commands(crate::git::GitPushCommandsRequest {
550 common_git_dir: request.common_git_dir,
551 format: request.format,
552 remote: remote_url,
553 command_forces: command_forces.clone(),
554 pack_objects: request.plan.pack_objects.clone(),
555 })?;
556 let commands = plan.commands.clone();
557 let execution = if commands.is_empty() {
558 PushExecution::Noop
559 } else {
560 PushExecution::Git(plan)
561 };
562 Ok(PushPlan {
563 commands,
564 execution,
565 })
566 }
567 PushDestination::Local {
568 git_dir: remote_git_dir,
569 common_git_dir: remote_common_git_dir,
570 } => {
571 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
572 if remote_format != request.format {
573 return Err(GitError::InvalidObjectId(format!(
574 "remote repository uses {}, local repository uses {}",
575 remote_format.name(),
576 request.format.name()
577 )));
578 }
579 let remote_refs =
580 crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
581 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
582 reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
583 let execution = if commands.is_empty() {
584 PushExecution::Noop
585 } else {
586 PushExecution::Local {
587 remote_git_dir: remote_git_dir.to_path_buf(),
588 remote_common_git_dir: remote_common_git_dir.to_path_buf(),
589 remote_refs,
590 command_forces,
591 pack_objects: request.plan.pack_objects.clone(),
592 }
593 };
594 Ok(PushPlan {
595 commands,
596 execution,
597 })
598 }
599 }
600}
601
602pub fn execute_push_plan(
604 request: PushRequest<'_>,
605 services: &mut PushServices<'_>,
606 plan: PushPlan,
607) -> Result<PushOutcome> {
608 let _ = (request.config, request.remote);
609 let _ = &mut services.progress;
610 if plan.commands.is_empty() {
611 return Ok(PushOutcome::default());
612 }
613 match plan.execution {
614 PushExecution::Noop => Ok(PushOutcome::default()),
615 #[cfg(feature = "http")]
616 PushExecution::Http {
617 remote_url,
618 features,
619 advertisements,
620 pack_objects,
621 } => execute_push_http(
622 request,
623 services.credentials,
624 plan.commands,
625 remote_url,
626 features,
627 advertisements,
628 pack_objects,
629 ),
630 PushExecution::Ssh(plan) => crate::ssh::execute_push_ssh_plan(request, plan),
631 PushExecution::Git(plan) => crate::git::execute_push_git_plan(request, plan),
632 PushExecution::Local {
633 remote_git_dir,
634 remote_common_git_dir,
635 remote_refs,
636 command_forces,
637 pack_objects,
638 } => execute_push_local(
639 request,
640 plan.commands,
641 remote_git_dir,
642 remote_common_git_dir,
643 remote_refs,
644 command_forces,
645 pack_objects,
646 ),
647 }
648}
649
650pub fn execute_push_action_plan(
652 request: PushActionRequest<'_>,
653 services: &mut PushServices<'_>,
654 plan: PushPlan,
655) -> Result<PushOutcome> {
656 let refspecs: &[String] = &[];
657 execute_push_plan(
658 PushRequest {
659 git_dir: request.git_dir,
660 common_git_dir: request.common_git_dir,
661 format: request.format,
662 config: request.config,
663 remote: request.remote,
664 destination: request.destination,
665 refspecs,
666 options: &request.plan.options,
667 },
668 services,
669 plan,
670 )
671}
672
673#[cfg(feature = "http")]
676struct PushHttpRequest<'a> {
677 git_dir: &'a Path,
678 common_git_dir: &'a Path,
679 format: ObjectFormat,
680 remote_url: &'a RemoteUrl,
681 refspecs: &'a [String],
682 options: &'a PushOptions,
683 credentials: &'a mut dyn CredentialProvider,
684}
685
686#[cfg(feature = "http")]
687fn plan_push_http(request: PushHttpRequest<'_>) -> Result<PushPlan> {
688 let PushHttpRequest {
689 git_dir,
690 common_git_dir,
691 format,
692 remote_url,
693 refspecs,
694 options,
695 credentials,
696 } = request;
697 let client = crate::http::new_http_client();
698 let discovered = crate::http::http_service_advertisements(
699 &client,
700 remote_url,
701 format,
702 GitService::ReceivePack,
703 credentials,
704 )?;
705 let advertisement_set = discovered.set;
706 let features = advertised_receive_pack_features(&advertisement_set.refs)?;
707 verify_remote_object_format(&features, format)?;
708
709 let local_store = FileRefStore::new(git_dir, format);
710 let mut local_refs = local_push_source_refs(&local_store, format)?;
711 add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
712 let command_forces = plan_push_command_forces(
713 format,
714 &local_refs,
715 &advertisement_set.refs,
716 refspecs,
717 options.force,
718 )?;
719 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
720 reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
721 let commands = commands_from_forces(&command_forces);
722 let execution = if commands.is_empty() {
723 PushExecution::Noop
724 } else {
725 PushExecution::Http {
726 remote_url: remote_url.clone(),
727 features,
728 advertisements: advertisement_set.refs,
729 pack_objects: Vec::new(),
730 }
731 };
732 Ok(PushPlan {
733 commands,
734 execution,
735 })
736}
737
738#[cfg(feature = "http")]
739fn execute_push_http(
740 request: PushRequest<'_>,
741 credentials: &mut dyn CredentialProvider,
742 commands: Vec<ReceivePackCommand>,
743 remote_url: RemoteUrl,
744 features: ReceivePackFeatures,
745 advertisements: Vec<RefAdvertisement>,
746 pack_objects: Vec<ObjectId>,
747) -> Result<PushOutcome> {
748 let client = crate::http::new_http_client();
749 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
750 let body = build_receive_pack_body(&PushPackRequest {
751 local_db: &local_db,
752 format: request.format,
753 commands: &commands,
754 pack_objects: &pack_objects,
755 remote_advertisements: &advertisements,
756 features: &features,
757 options: receive_pack_push_options(&features, request.format, request.options.quiet),
758 thin: false,
759 })?;
760 let url = http_smart_rpc_url(&remote_url, GitService::ReceivePack)?;
761 let content_type = smart_http_rpc_request_content_type(GitService::ReceivePack)?;
762 let mut response = crate::http::http_send_with_auth(&remote_url, credentials, |auth| {
763 client.post(
764 &url,
765 &content_type,
766 &crate::http::http_authorization_headers(auth),
767 &body,
768 )
769 })?;
770 crate::http::http_check_status(&response, &url)?;
771 crate::http::http_validate_content_type(
772 &response,
773 &smart_http_rpc_result_content_type(GitService::ReceivePack)?,
774 )?;
775
776 let report = if features.report_status {
777 let report = read_receive_pack_report_status(&mut response.body)?;
778 validate_receive_pack_report(&report)?;
779 Some(report)
780 } else {
781 let mut sink = Vec::new();
782 response.body.read_to_end(&mut sink)?;
783 None
784 };
785 Ok(PushOutcome { commands, report })
786}
787
788struct PushLocalRequest<'a> {
792 git_dir: &'a Path,
793 common_git_dir: &'a Path,
794 format: ObjectFormat,
795 remote: &'a str,
796 remote_git_dir: &'a Path,
797 remote_common_git_dir: &'a Path,
798 refspecs: &'a [String],
799 options: &'a PushOptions,
800}
801
802fn plan_push_local(request: PushLocalRequest<'_>) -> Result<PushPlan> {
803 let PushLocalRequest {
804 git_dir,
805 common_git_dir,
806 format,
807 remote,
808 remote_git_dir,
809 remote_common_git_dir,
810 refspecs,
811 options,
812 } = request;
813 let _ = remote;
814 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
815 if remote_format != format {
816 return Err(GitError::InvalidObjectId(format!(
817 "remote repository uses {}, local repository uses {}",
818 remote_format.name(),
819 format.name()
820 )));
821 }
822
823 let local_store = FileRefStore::new(git_dir, format);
824 let mut local_refs = local_push_source_refs(&local_store, format)?;
825 add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
826 let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
827 let command_forces =
828 plan_push_command_forces(format, &local_refs, &remote_refs, refspecs, options.force)?;
829 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
830 reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
831 let commands = commands_from_forces(&command_forces);
832 let execution = if commands.is_empty() {
833 PushExecution::Noop
834 } else {
835 PushExecution::Local {
836 remote_git_dir: remote_git_dir.to_path_buf(),
837 remote_common_git_dir: remote_common_git_dir.to_path_buf(),
838 remote_refs,
839 command_forces,
840 pack_objects: Vec::new(),
841 }
842 };
843 Ok(PushPlan {
844 commands,
845 execution,
846 })
847}
848
849fn execute_push_local(
850 request: PushRequest<'_>,
851 commands: Vec<ReceivePackCommand>,
852 remote_git_dir: PathBuf,
853 remote_common_git_dir: PathBuf,
854 remote_refs: Vec<RefAdvertisement>,
855 _command_forces: Vec<(ReceivePackCommand, bool)>,
856 pack_objects: Vec<ObjectId>,
857) -> Result<PushOutcome> {
858 let remote_excluded_tips = remote_refs
859 .iter()
860 .map(|reference| reference.oid)
861 .collect::<Vec<_>>();
862 let starts = push_pack_roots(&commands, &pack_objects);
863 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
864 let remote_db = FileObjectDatabase::from_git_dir(&remote_common_git_dir, request.format);
865 let remote_excluded =
866 collect_reachable_object_ids(&remote_db, request.format, remote_excluded_tips)?;
867 let packfile = if starts.is_empty() {
868 Vec::new()
869 } else {
870 b"PACK".to_vec()
871 };
872 let receive_request = ReceivePackPushRequest {
873 commands: ReceivePackRequest {
874 shallow: Vec::new(),
875 commands: commands.clone(),
876 capabilities: Vec::new(),
877 },
878 push_options: None,
879 packfile,
880 };
881 let report = crate::local::receive_pack_reachable_pack_into_local_repository(
882 &remote_git_dir,
883 request.format,
884 &receive_request,
885 &local_db,
886 starts,
887 remote_excluded,
888 )?;
889 validate_receive_pack_report(&report)?;
890 Ok(PushOutcome {
891 commands,
892 report: Some(report),
893 })
894}
895
896pub struct PushReportRequest<'a> {
898 pub git_dir: &'a Path,
900 pub common_git_dir: &'a Path,
902 pub format: ObjectFormat,
904 pub remote_git_dir: &'a Path,
906 pub remote_common_git_dir: &'a Path,
908 pub refspecs: &'a [String],
910 pub force: bool,
912 pub atomic: bool,
914 pub dry_run: bool,
916 pub force_with_lease: &'a [(String, Option<ObjectId>)],
919 pub force_with_lease_default: bool,
924}
925
926pub fn push_local_with_report(
934 request: PushReportRequest<'_>,
935 config: &GitConfig,
936) -> Result<PushStatusReport> {
937 let format = request.format;
938 let remote_format = crate::object_format_for_git_dir(request.remote_common_git_dir)?;
939 if remote_format != format {
940 return Err(GitError::InvalidObjectId(format!(
941 "remote repository uses {}, local repository uses {}",
942 remote_format.name(),
943 format.name()
944 )));
945 }
946 let local_store = FileRefStore::new(request.git_dir, format);
947 let mut local_refs = local_push_source_refs(&local_store, format)?;
948 add_revision_push_sources(request.git_dir, format, request.refspecs, &mut local_refs);
949 let remote_refs = crate::local::local_fetch_advertisements(request.remote_git_dir, format)?;
950 let planned = plan_push_command_sources(
951 format,
952 &local_refs,
953 &remote_refs,
954 request.refspecs,
955 request.force,
956 )?;
957 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, format);
958
959 let mut refs: Vec<PushReportRef> = Vec::new();
962 for plan in &planned {
963 let status = classify_push_command(
964 &local_db,
965 format,
966 plan,
967 &request,
968 config,
969 request.remote_git_dir,
970 )?;
971 let forced = matches!(status, PushRefStatus::Ok)
975 && !plan.command.old_id.is_null()
976 && !plan.command.new_id.is_null()
977 && !is_fast_forward(&local_db, format, &plan.command.old_id, &plan.command.new_id)?;
978 refs.push(PushReportRef {
979 src: plan.source.clone(),
980 dst: plan.command.name.clone(),
981 old_id: plan.command.old_id,
982 new_id: plan.command.new_id,
983 forced,
984 status,
985 });
986 }
987
988 let any_local_reject = refs.iter().any(|reference| {
989 matches!(
990 reference.status,
991 PushRefStatus::RejectNonFastForward | PushRefStatus::RejectStale
992 )
993 });
994
995 if request.atomic && any_local_reject {
999 for reference in &mut refs {
1000 if matches!(reference.status, PushRefStatus::Ok) {
1001 reference.status = PushRefStatus::AtomicPushFailed;
1002 }
1003 }
1004 return Ok(PushStatusReport { refs });
1005 }
1006
1007 if request.dry_run {
1008 return Ok(PushStatusReport { refs });
1009 }
1010
1011 let send: Vec<ReceivePackCommand> = refs
1013 .iter()
1014 .filter(|reference| matches!(reference.status, PushRefStatus::Ok))
1015 .map(|reference| ReceivePackCommand {
1016 old_id: reference.old_id,
1017 new_id: reference.new_id,
1018 name: reference.dst.clone(),
1019 })
1020 .collect();
1021
1022 if !send.is_empty() {
1023 let remote_excluded_tips: Vec<ObjectId> =
1024 remote_refs.iter().map(|reference| reference.oid).collect();
1025 let pack_objects: Vec<ObjectId> = Vec::new();
1026 let starts = push_pack_roots(&send, &pack_objects);
1027 let remote_db =
1028 FileObjectDatabase::from_git_dir(request.remote_common_git_dir, format);
1029 let remote_excluded =
1030 collect_reachable_object_ids(&remote_db, format, remote_excluded_tips)?;
1031 let packfile = if starts.is_empty() {
1032 Vec::new()
1033 } else {
1034 b"PACK".to_vec()
1035 };
1036 let receive_request = ReceivePackPushRequest {
1037 commands: ReceivePackRequest {
1038 shallow: Vec::new(),
1039 commands: send.clone(),
1040 capabilities: Vec::new(),
1041 },
1042 push_options: None,
1043 packfile,
1044 };
1045 let report = crate::local::receive_pack_reachable_pack_into_local_repository(
1046 request.remote_git_dir,
1047 format,
1048 &receive_request,
1049 &local_db,
1050 starts,
1051 remote_excluded,
1052 )?;
1053 if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1055 for reference in &mut refs {
1056 if matches!(reference.status, PushRefStatus::Ok) {
1057 reference.status = PushRefStatus::RemoteReject(format!("unpacker error: {message}"));
1058 }
1059 }
1060 }
1061 for command_status in &report.commands {
1062 if let ReceivePackCommandStatus::Ng { name, message } = command_status {
1063 for reference in &mut refs {
1064 if reference.dst == *name && matches!(reference.status, PushRefStatus::Ok) {
1065 reference.status = PushRefStatus::RemoteReject(message.clone());
1066 }
1067 }
1068 }
1069 }
1070 }
1071
1072 Ok(PushStatusReport { refs })
1073}
1074
1075fn classify_push_command(
1079 local_db: &FileObjectDatabase,
1080 format: ObjectFormat,
1081 plan: &PlannedPushCommand,
1082 request: &PushReportRequest<'_>,
1083 _config: &GitConfig,
1084 _remote_git_dir: &Path,
1085) -> Result<PushRefStatus> {
1086 let command = &plan.command;
1087
1088 if command.old_id == command.new_id {
1091 return Ok(PushRefStatus::UpToDate);
1092 }
1093
1094 if let Some((_, expected)) = request
1098 .force_with_lease
1099 .iter()
1100 .find(|(dst, _)| *dst == command.name)
1101 {
1102 let actual = if command.old_id.is_null() {
1103 None
1104 } else {
1105 Some(command.old_id)
1106 };
1107 if *expected != actual {
1108 return Ok(PushRefStatus::RejectStale);
1109 }
1110 return Ok(PushRefStatus::Ok);
1112 }
1113
1114 if !plan.force
1117 && command.name.starts_with("refs/heads/")
1118 && !command.old_id.is_null()
1119 && !command.new_id.is_null()
1120 && !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?
1121 {
1122 return Ok(PushRefStatus::RejectNonFastForward);
1123 }
1124
1125 Ok(PushRefStatus::Ok)
1126}
1127
1128fn is_fast_forward(
1131 db: &FileObjectDatabase,
1132 format: ObjectFormat,
1133 old: &ObjectId,
1134 new: &ObjectId,
1135) -> Result<bool> {
1136 let ancestors = ancestor_depths(db, format, new)?;
1137 Ok(ancestors.contains_key(old))
1138}
1139
1140#[cfg(feature = "http")]
1143fn advertised_receive_pack_features(
1144 advertisements: &[RefAdvertisement],
1145) -> Result<ReceivePackFeatures> {
1146 advertisements
1147 .first()
1148 .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
1149 .transpose()
1150 .map(Option::unwrap_or_default)
1151}
1152
1153#[cfg(feature = "http")]
1156fn verify_remote_object_format(features: &ReceivePackFeatures, format: ObjectFormat) -> Result<()> {
1157 if let Some(remote_format) = features.object_format {
1158 if remote_format != format {
1159 return Err(GitError::InvalidObjectId(format!(
1160 "remote repository uses {}, local repository uses {}",
1161 remote_format.name(),
1162 format.name()
1163 )));
1164 }
1165 } else if format != ObjectFormat::Sha1 {
1166 return Err(GitError::InvalidObjectId(format!(
1167 "remote repository did not advertise object-format for {} push",
1168 format.name()
1169 )));
1170 }
1171 Ok(())
1172}
1173
1174#[cfg(feature = "http")]
1179fn receive_pack_push_options(
1180 features: &ReceivePackFeatures,
1181 format: ObjectFormat,
1182 quiet: bool,
1183) -> ReceivePackPushRequestOptions {
1184 ReceivePackPushRequestOptions {
1185 report_status: features.report_status,
1186 ofs_delta: features.ofs_delta,
1187 quiet: quiet && features.quiet,
1188 object_format: features
1189 .object_format
1190 .filter(|_| format != ObjectFormat::Sha1),
1191 ..ReceivePackPushRequestOptions::default()
1192 }
1193}
1194
1195fn plan_push_command_forces(
1200 format: ObjectFormat,
1201 local_refs: &[PushSourceRef],
1202 remote_refs: &[RefAdvertisement],
1203 refspecs: &[String],
1204 force: bool,
1205) -> Result<Vec<(ReceivePackCommand, bool)>> {
1206 let parsed_refspecs = refspecs
1207 .iter()
1208 .map(|refspec| {
1209 let normalized =
1210 normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
1211 parse_refspec(&normalized)
1212 })
1213 .collect::<Result<Vec<_>>>()?;
1214 let mut command_forces = Vec::new();
1215 for refspec in &parsed_refspecs {
1216 for command in plan_push_commands(
1217 format,
1218 local_refs,
1219 remote_refs,
1220 std::slice::from_ref(refspec),
1221 )? {
1222 command_forces.push((command, force || refspec.force));
1223 }
1224 }
1225 Ok(command_forces)
1226}
1227
1228struct PlannedPushCommand {
1231 command: ReceivePackCommand,
1232 force: bool,
1233 source: Option<String>,
1234}
1235
1236fn plan_push_command_sources(
1242 format: ObjectFormat,
1243 local_refs: &[PushSourceRef],
1244 remote_refs: &[RefAdvertisement],
1245 refspecs: &[String],
1246 force: bool,
1247) -> Result<Vec<PlannedPushCommand>> {
1248 let mut planned = Vec::new();
1249 for refspec in refspecs {
1250 let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
1251 let parsed = parse_refspec(&normalized)?;
1252 let commands =
1253 plan_push_commands(format, local_refs, remote_refs, std::slice::from_ref(&parsed))?;
1254 for command in commands {
1255 let source = push_command_source_name(&parsed, &command);
1256 planned.push(PlannedPushCommand {
1257 command,
1258 force: force || parsed.force,
1259 source,
1260 });
1261 }
1262 }
1263 Ok(planned)
1264}
1265
1266fn push_command_source_name(refspec: &RefSpec, command: &ReceivePackCommand) -> Option<String> {
1271 let src = refspec.src.as_deref()?;
1272 if !refspec.pattern {
1273 return Some(src.to_string());
1274 }
1275 let (src_prefix, src_suffix) = src.split_once('*')?;
1276 let dst = refspec.dst.as_deref()?;
1277 let (dst_prefix, dst_suffix) = dst.split_once('*')?;
1278 let stem = command
1279 .name
1280 .strip_prefix(dst_prefix)
1281 .and_then(|rest| rest.strip_suffix(dst_suffix))?;
1282 Some(format!("{src_prefix}{stem}{src_suffix}"))
1283}
1284
1285fn add_revision_push_sources(
1286 git_dir: &Path,
1287 format: ObjectFormat,
1288 refspecs: &[String],
1289 local_refs: &mut Vec<PushSourceRef>,
1290) {
1291 for refspec in refspecs {
1292 let refspec = refspec.strip_prefix('+').unwrap_or(refspec);
1293 let src = refspec.split_once(':').map_or(refspec, |(src, _)| src);
1294 if src.is_empty() || src == "HEAD" || src.starts_with("refs/") {
1295 continue;
1296 }
1297 if local_refs.iter().any(|reference| {
1298 reference.name == src
1299 || reference.name == format!("refs/heads/{src}")
1300 || reference.name == format!("refs/tags/{src}")
1301 }) {
1302 continue;
1303 }
1304 if let Ok(oid) = sley_rev::resolve_revision(git_dir, format, src)
1305 && !local_refs.iter().any(|reference| reference.name == src)
1306 {
1307 local_refs.push(PushSourceRef {
1308 name: src.to_string(),
1309 oid,
1310 });
1311 }
1312 }
1313}
1314
1315fn normalize_push_refspec_for_sources(
1316 refspec: &str,
1317 local_refs: &[PushSourceRef],
1318 remote_refs: &[RefAdvertisement],
1319) -> Result<String> {
1320 let (force, refspec) = refspec
1321 .strip_prefix('+')
1322 .map_or((false, refspec), |refspec| (true, refspec));
1323 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1324 let (src, src_kind) = normalize_push_source_refname(src, local_refs);
1325 let dst = normalize_push_destination_refname(dst, src_kind, remote_refs)?;
1326 format!("{src}:{dst}")
1327 } else {
1328 let (name, _) = normalize_push_source_refname(refspec, local_refs);
1329 let dst = match count_refspec_match_dst(&name, remote_refs) {
1336 DstMatch::Unique(matched) => matched.to_string(),
1337 DstMatch::None => name.clone(),
1338 DstMatch::Ambiguous => {
1339 return Err(GitError::Command(format!(
1340 "dst refspec {name} matches more than one"
1341 )));
1342 }
1343 };
1344 format!("{name}:{dst}")
1345 };
1346 Ok(if force {
1347 format!("+{normalized}")
1348 } else {
1349 normalized
1350 })
1351}
1352
1353fn refname_match_rank(abbrev: &str, full_name: &str) -> Option<usize> {
1357 const RULES: [&str; 6] = [
1358 "{}",
1359 "refs/{}",
1360 "refs/tags/{}",
1361 "refs/heads/{}",
1362 "refs/remotes/{}",
1363 "refs/remotes/{}/HEAD",
1364 ];
1365 for (idx, rule) in RULES.iter().enumerate() {
1366 let (prefix, suffix) = rule.split_once("{}").unwrap_or((rule, ""));
1367 if full_name == format!("{prefix}{abbrev}{suffix}") {
1368 return Some(RULES.len() - idx);
1369 }
1370 }
1371 None
1372}
1373
1374enum DstMatch<'a> {
1376 Unique(&'a str),
1378 None,
1380 Ambiguous,
1382}
1383
1384fn count_refspec_match_dst<'a>(pattern: &str, remote_refs: &'a [RefAdvertisement]) -> DstMatch<'a> {
1391 let patlen = pattern.len();
1392 let mut strong: Option<&str> = None;
1393 let mut strong_count = 0usize;
1394 let mut weak: Option<&str> = None;
1395 let mut weak_count = 0usize;
1396 for advert in remote_refs {
1397 let name = advert.name.as_str();
1398 if refname_match_rank(pattern, name).is_none() {
1399 continue;
1400 }
1401 let namelen = name.len();
1402 let is_weak = namelen != patlen
1403 && patlen + 5 != namelen
1404 && !name.starts_with("refs/heads/")
1405 && !name.starts_with("refs/tags/");
1406 if is_weak {
1407 weak = Some(name);
1408 weak_count += 1;
1409 } else {
1410 strong = Some(name);
1411 strong_count += 1;
1412 }
1413 }
1414 match (strong_count, weak_count, strong, weak) {
1415 (1, _, Some(matched), _) => DstMatch::Unique(matched),
1416 (0, 1, _, Some(matched)) => DstMatch::Unique(matched),
1417 (0, 0, _, _) => DstMatch::None,
1418 _ => DstMatch::Ambiguous,
1419 }
1420}
1421
1422#[derive(Clone, Copy)]
1423enum PushSourceKind {
1424 Branch,
1425 Tag,
1426 Other,
1430 Unqualifiable,
1434}
1435
1436fn normalize_push_source_refname(
1437 name: &str,
1438 local_refs: &[PushSourceRef],
1439) -> (String, PushSourceKind) {
1440 if name.is_empty() || name == "HEAD" || name == "@" || name.starts_with("refs/") {
1443 return (name.to_string(), PushSourceKind::Other);
1444 }
1445 let branch = format!("refs/heads/{name}");
1446 let tag = format!("refs/tags/{name}");
1447 let has_branch = local_refs.iter().any(|reference| reference.name == branch);
1448 let has_tag = local_refs.iter().any(|reference| reference.name == tag);
1449 if has_tag && !has_branch {
1450 (tag, PushSourceKind::Tag)
1451 } else if has_branch {
1452 (branch, PushSourceKind::Branch)
1453 } else if local_refs.iter().any(|reference| reference.name == name) {
1454 (name.to_string(), PushSourceKind::Unqualifiable)
1458 } else {
1459 (branch, PushSourceKind::Branch)
1460 }
1461}
1462
1463fn normalize_push_destination_refname(
1464 name: &str,
1465 src_kind: PushSourceKind,
1466 remote_refs: &[RefAdvertisement],
1467) -> Result<String> {
1468 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1469 return Ok(name.to_string());
1470 }
1471 match count_refspec_match_dst(name, remote_refs) {
1477 DstMatch::Unique(matched) => Ok(matched.to_string()),
1478 DstMatch::Ambiguous => Err(GitError::Command(format!(
1479 "dst refspec {name} matches more than one"
1480 ))),
1481 DstMatch::None => match src_kind {
1482 PushSourceKind::Tag => Ok(format!("refs/tags/{name}")),
1483 PushSourceKind::Branch | PushSourceKind::Other => Ok(format!("refs/heads/{name}")),
1484 PushSourceKind::Unqualifiable => Err(GitError::Command(format!(
1488 "the destination you provided is not a full refname (i.e., starting with \"refs/\"); unable to guess the destination for {name}"
1489 ))),
1490 },
1491 }
1492}
1493
1494fn commands_from_forces(command_forces: &[(ReceivePackCommand, bool)]) -> Vec<ReceivePackCommand> {
1496 command_forces
1497 .iter()
1498 .map(|(command, _)| command.clone())
1499 .collect()
1500}
1501
1502fn receive_pack_commands_from_action_plan(
1503 format: ObjectFormat,
1504 plan: &PushActionPlan,
1505) -> Result<Vec<ReceivePackCommand>> {
1506 let zero = ObjectId::null(format);
1507 for oid in &plan.pack_objects {
1508 if oid.format() != format {
1509 return Err(GitError::InvalidObjectId(format!(
1510 "push pack object {oid} has {} object id for {} repository",
1511 oid.format().name(),
1512 format.name()
1513 )));
1514 }
1515 }
1516 plan.commands
1517 .iter()
1518 .map(|command| {
1519 let old_id = command.expected_old.unwrap_or(zero);
1520 let new_id = command.src.unwrap_or(zero);
1521 if old_id.format() != format {
1522 return Err(GitError::InvalidObjectId(format!(
1523 "push command {} expected old has {} object id for {} repository",
1524 command.dst,
1525 old_id.format().name(),
1526 format.name()
1527 )));
1528 }
1529 if new_id.format() != format {
1530 return Err(GitError::InvalidObjectId(format!(
1531 "push command {} new id has {} object id for {} repository",
1532 command.dst,
1533 new_id.format().name(),
1534 format.name()
1535 )));
1536 }
1537 Ok(ReceivePackCommand {
1538 old_id,
1539 new_id,
1540 name: command.dst.clone(),
1541 })
1542 })
1543 .collect()
1544}
1545
1546pub fn validate_receive_pack_report(report: &ReceivePackReportStatus) -> Result<()> {
1549 if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1550 return Err(GitError::Command(format!(
1551 "failed to push some refs: unpack failed: {message}"
1552 )));
1553 }
1554 for status in &report.commands {
1555 if let ReceivePackCommandStatus::Ng { name, message } = status {
1556 return Err(GitError::Command(format!(
1557 "failed to push {name}: {message}"
1558 )));
1559 }
1560 }
1561 Ok(())
1562}
1563
1564pub fn local_push_source_refs(
1568 store: &FileRefStore,
1569 format: ObjectFormat,
1570) -> Result<Vec<PushSourceRef>> {
1571 let mut refs = Vec::new();
1572 for reference in store.list_refs()? {
1573 let Some((oid, _)) = resolve_for_each_ref_target(store, &reference)? else {
1574 continue;
1575 };
1576 if oid.format() != format {
1577 return Err(GitError::InvalidObjectId(format!(
1578 "local ref {} has {} object id for {} repository",
1579 reference.name,
1580 oid.format().name(),
1581 format.name()
1582 )));
1583 }
1584 refs.push(PushSourceRef {
1585 name: reference.name.clone(),
1586 oid,
1587 });
1588 if let Some(short) = reference.name.strip_prefix("refs/heads/") {
1589 refs.push(PushSourceRef {
1590 name: short.to_string(),
1591 oid,
1592 });
1593 }
1594 if let Some(short) = reference.name.strip_prefix("refs/tags/") {
1595 refs.push(PushSourceRef {
1596 name: short.to_string(),
1597 oid,
1598 });
1599 }
1600 }
1601 if let Some(target) = store.read_ref("HEAD")? {
1602 let head = Ref {
1603 name: "HEAD".to_string(),
1604 target,
1605 };
1606 if let Some((oid, _)) = resolve_for_each_ref_target(store, &head)?
1607 && oid.format() == format
1608 {
1609 refs.push(PushSourceRef {
1610 name: "HEAD".to_string(),
1611 oid,
1612 });
1613 }
1614 }
1615 Ok(refs)
1616}
1617
1618pub fn normalize_push_refspec(refspec: &str) -> String {
1622 let (force, refspec) = refspec
1623 .strip_prefix('+')
1624 .map_or((false, refspec), |refspec| (true, refspec));
1625 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1626 let src = normalize_push_refname(src);
1627 let dst = normalize_push_refname(dst);
1628 format!("{src}:{dst}")
1629 } else {
1630 let name = normalize_push_refname(refspec);
1631 format!("{name}:{name}")
1632 };
1633 if force {
1634 format!("+{normalized}")
1635 } else {
1636 normalized
1637 }
1638}
1639
1640pub fn normalize_push_refname(name: &str) -> String {
1643 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
1644 name.to_string()
1645 } else {
1646 format!("refs/heads/{name}")
1647 }
1648}
1649
1650pub fn reject_non_fast_forward_pushes(
1654 local_db: &FileObjectDatabase,
1655 format: ObjectFormat,
1656 command_forces: &[(ReceivePackCommand, bool)],
1657) -> Result<()> {
1658 for (command, force) in command_forces {
1659 if *force
1660 || !command.name.starts_with("refs/heads/")
1661 || command.old_id.is_null()
1662 || command.new_id.is_null()
1663 {
1664 continue;
1665 }
1666 let ancestors = ancestor_depths(local_db, format, &command.new_id)?;
1667 if !ancestors.contains_key(&command.old_id) {
1668 let short = command.name.trim_start_matches("refs/heads/");
1669 return Err(GitError::Command(format!(
1670 "failed to push some refs: non-fast-forward update to {short}"
1671 )));
1672 }
1673 }
1674 Ok(())
1675}
1676
1677fn ancestor_depths(
1681 db: &FileObjectDatabase,
1682 format: ObjectFormat,
1683 start: &ObjectId,
1684) -> Result<HashMap<ObjectId, usize>> {
1685 let mut depths = HashMap::new();
1686 let mut pending = std::collections::VecDeque::from([(start.clone(), 0usize)]);
1687 while let Some((oid, depth)) = pending.pop_front() {
1688 if depths.get(&oid).is_some_and(|existing| *existing <= depth) {
1689 continue;
1690 }
1691 depths.insert(oid, depth);
1692 let object = db.read_object(&oid)?;
1693 if object.object_type != ObjectType::Commit {
1694 return Err(GitError::InvalidObject(format!(
1695 "expected commit {oid}, found {}",
1696 object.object_type.as_str()
1697 )));
1698 }
1699 let commit = Commit::parse_ref(format, &object.body)?;
1700 for parent in commit.parents {
1701 pending.push_back((parent, depth + 1));
1702 }
1703 }
1704 Ok(depths)
1705}
1706
1707fn resolve_for_each_ref_target(
1710 store: &FileRefStore,
1711 reference: &Ref,
1712) -> Result<Option<(ObjectId, Option<String>)>> {
1713 let mut target = reference.target.clone();
1714 let mut symref = None;
1715 for _ in 0..5 {
1716 match target {
1717 RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
1718 RefTarget::Symbolic(name) => {
1719 symref.get_or_insert_with(|| name.clone());
1720 let Some(next) = store.read_ref(&name)? else {
1721 return Ok(None);
1722 };
1723 target = next;
1724 }
1725 }
1726 }
1727 Ok(None)
1728}
1729
1730#[cfg(test)]
1731mod tests {
1732 use super::*;
1733 use std::fs;
1734 use std::sync::atomic::{AtomicU64, Ordering};
1735
1736 use sley_formats::RepositoryLayout;
1737 use sley_object::{Commit, EncodedObject, ObjectType, Tree};
1738 use sley_odb::{FileObjectDatabase, ObjectWriter};
1739 use sley_protocol::{ReceivePackCommandStatus, ReceivePackUnpackStatus};
1740 use sley_refs::{RefTarget, RefUpdate};
1741
1742 use crate::{NoCredentials, SilentProgress};
1743
1744 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1745
1746 fn temp_repo(name: &str) -> PathBuf {
1747 let dir = std::env::temp_dir().join(format!(
1748 "sley-remote-push-{name}-{}-{}",
1749 std::process::id(),
1750 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1751 ));
1752 let _ = fs::remove_dir_all(&dir);
1753 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
1754 .expect("test repository should initialize");
1755 dir.join(".git")
1756 }
1757
1758 fn write_commit(git_dir: &Path, parents: Vec<ObjectId>, message: &str) -> ObjectId {
1759 let format = ObjectFormat::Sha1;
1760 let db = FileObjectDatabase::from_git_dir(git_dir, format);
1761 let tree = db
1762 .write_object(EncodedObject::new(
1763 ObjectType::Tree,
1764 Tree { entries: vec![] }.write(),
1765 ))
1766 .expect("tree should write");
1767 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
1768 db.write_object(EncodedObject::new(
1769 ObjectType::Commit,
1770 Commit {
1771 tree,
1772 parents,
1773 author: identity.clone(),
1774 committer: identity,
1775 encoding: None,
1776 message: format!("{message}\n").into_bytes(),
1777 }
1778 .write(),
1779 ))
1780 .expect("commit should write")
1781 }
1782
1783 fn set_ref(git_dir: &Path, name: &str, target: RefTarget) {
1784 let store = FileRefStore::new(git_dir, ObjectFormat::Sha1);
1785 let mut tx = store.transaction();
1786 tx.update(RefUpdate {
1787 name: name.to_string(),
1788 expected: None,
1789 new: target,
1790 reflog: None,
1791 });
1792 tx.commit().expect("ref should update");
1793 }
1794
1795 fn default_options() -> PushOptions {
1796 PushOptions {
1797 quiet: true,
1798 force: false,
1799 }
1800 }
1801
1802 #[test]
1803 fn push_action_plan_infers_pack_roots_from_non_delete_commands() {
1804 let repo = temp_repo("action-plan-infer-roots");
1805 let first = write_commit(&repo, Vec::new(), "first");
1806 let second = write_commit(&repo, vec![first], "second");
1807
1808 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1809 vec![
1810 PushCommand {
1811 src: Some(first),
1812 dst: "refs/heads/main".into(),
1813 expected_old: None,
1814 force: false,
1815 },
1816 PushCommand {
1817 src: Some(second),
1818 dst: "refs/heads/topic".into(),
1819 expected_old: Some(first),
1820 force: true,
1821 },
1822 ],
1823 default_options(),
1824 );
1825
1826 assert_eq!(plan.pack_objects, vec![first, second]);
1827 assert!(!plan.commands[0].force);
1828 assert!(plan.commands[1].force);
1829 }
1830
1831 #[test]
1832 fn push_action_plan_inferred_pack_roots_exclude_deletes() {
1833 let repo = temp_repo("action-plan-delete-roots");
1834 let old = write_commit(&repo, Vec::new(), "old");
1835 let new = write_commit(&repo, vec![old], "new");
1836
1837 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1838 vec![
1839 PushCommand {
1840 src: None,
1841 dst: "refs/heads/remove".into(),
1842 expected_old: Some(old),
1843 force: false,
1844 },
1845 PushCommand {
1846 src: Some(new),
1847 dst: "refs/heads/keep".into(),
1848 expected_old: Some(old),
1849 force: false,
1850 },
1851 ],
1852 default_options(),
1853 );
1854
1855 assert_eq!(plan.pack_objects, vec![new]);
1856 }
1857
1858 #[test]
1859 fn push_action_plan_inferred_pack_roots_dedupe_first_seen_order() {
1860 let repo = temp_repo("action-plan-dedupe-roots");
1861 let first = write_commit(&repo, Vec::new(), "first");
1862 let second = write_commit(&repo, Vec::new(), "second");
1863
1864 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
1865 vec![
1866 PushCommand {
1867 src: Some(second),
1868 dst: "refs/heads/second".into(),
1869 expected_old: None,
1870 force: false,
1871 },
1872 PushCommand {
1873 src: Some(first),
1874 dst: "refs/heads/first".into(),
1875 expected_old: None,
1876 force: false,
1877 },
1878 PushCommand {
1879 src: Some(second),
1880 dst: "refs/tags/second".into(),
1881 expected_old: None,
1882 force: false,
1883 },
1884 PushCommand {
1885 src: Some(first),
1886 dst: "refs/tags/first".into(),
1887 expected_old: None,
1888 force: false,
1889 },
1890 ],
1891 default_options(),
1892 );
1893
1894 assert_eq!(plan.pack_objects, vec![second, first]);
1895 }
1896
1897 fn push_local_actions(
1898 local: &Path,
1899 remote: &Path,
1900 plan: &PushActionPlan,
1901 ) -> Result<PushOutcome> {
1902 let destination = PushDestination::Local {
1903 git_dir: remote.to_path_buf(),
1904 common_git_dir: remote.to_path_buf(),
1905 };
1906 let config = GitConfig::default();
1907 let mut credentials = NoCredentials;
1908 let mut progress = SilentProgress;
1909 push_actions(
1910 PushActionRequest {
1911 git_dir: local,
1912 common_git_dir: local,
1913 format: ObjectFormat::Sha1,
1914 config: &config,
1915 remote: "origin",
1916 destination: &destination,
1917 plan,
1918 },
1919 PushServices {
1920 credentials: &mut credentials,
1921 progress: &mut progress,
1922 },
1923 )
1924 }
1925
1926 #[test]
1927 fn local_push_returns_success_report_status_and_updates_ref() {
1928 let local = temp_repo("local-success");
1929 let remote = temp_repo("remote-success");
1930 let base = write_commit(&local, Vec::new(), "base");
1931 let tip = write_commit(&local, vec![base], "tip");
1932 set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
1933 set_ref(
1934 &local,
1935 "HEAD",
1936 RefTarget::Symbolic("refs/heads/main".into()),
1937 );
1938 let destination = PushDestination::Local {
1939 git_dir: remote.clone(),
1940 common_git_dir: remote.clone(),
1941 };
1942 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
1943 let options = default_options();
1944 let request = PushRequest {
1945 git_dir: &local,
1946 common_git_dir: &local,
1947 format: ObjectFormat::Sha1,
1948 config: &GitConfig::default(),
1949 remote: "origin",
1950 destination: &destination,
1951 refspecs: &refspecs,
1952 options: &options,
1953 };
1954 let mut credentials = NoCredentials;
1955 let mut progress = SilentProgress;
1956
1957 let outcome = push(
1958 request,
1959 PushServices {
1960 credentials: &mut credentials,
1961 progress: &mut progress,
1962 },
1963 )
1964 .expect("push should succeed");
1965
1966 assert_eq!(outcome.commands.len(), 1);
1967 let report = outcome.report.expect("local receive-pack reports status");
1968 assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
1969 assert!(matches!(
1970 report.commands.as_slice(),
1971 [ReceivePackCommandStatus::Ok { name }] if name == "refs/heads/main"
1972 ));
1973 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
1974 assert_eq!(
1975 remote_refs
1976 .read_ref("refs/heads/main")
1977 .expect("remote ref should read"),
1978 Some(RefTarget::Direct(tip))
1979 );
1980 }
1981
1982 #[test]
1983 fn local_push_actions_preserves_exact_old_new_update() {
1984 let local = temp_repo("actions-update-local");
1985 let remote = temp_repo("actions-update-remote");
1986 let base = write_commit(&local, Vec::new(), "base");
1987 let remote_base = write_commit(&remote, Vec::new(), "base");
1988 assert_eq!(remote_base, base);
1989 let tip = write_commit(&local, vec![base], "tip");
1990 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
1991 let plan = PushActionPlan::from_actions(
1992 vec![PushAction::Update {
1993 dst: "refs/heads/main".into(),
1994 old: base,
1995 new: tip,
1996 }],
1997 default_options(),
1998 );
1999
2000 let outcome = push_local_actions(&local, &remote, &plan).expect("push actions");
2001
2002 assert_eq!(outcome.commands.len(), 1);
2003 assert_eq!(outcome.commands[0].old_id, base);
2004 assert_eq!(outcome.commands[0].new_id, tip);
2005 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2006 assert_eq!(
2007 remote_refs
2008 .read_ref("refs/heads/main")
2009 .expect("remote ref should read"),
2010 Some(RefTarget::Direct(tip))
2011 );
2012 }
2013
2014 #[test]
2015 fn local_push_actions_honors_per_command_force() {
2016 let local = temp_repo("actions-command-force-local");
2017 let remote = temp_repo("actions-command-force-remote");
2018 let base = write_commit(&local, Vec::new(), "base");
2019 let remote_base = write_commit(&remote, Vec::new(), "base");
2020 assert_eq!(remote_base, base);
2021 let unrelated = write_commit(&local, Vec::new(), "unrelated");
2022 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2023
2024 let unforced = PushActionPlan::from_commands(
2025 vec![PushCommand {
2026 src: Some(unrelated),
2027 dst: "refs/heads/main".into(),
2028 expected_old: Some(base),
2029 force: false,
2030 }],
2031 default_options(),
2032 );
2033 let err = push_local_actions(&local, &remote, &unforced)
2034 .expect_err("non-fast-forward should reject without command force");
2035 assert!(err.to_string().contains("non-fast-forward"));
2036
2037 let forced = PushActionPlan::from_commands(
2038 vec![PushCommand {
2039 src: Some(unrelated),
2040 dst: "refs/heads/main".into(),
2041 expected_old: Some(base),
2042 force: true,
2043 }],
2044 default_options(),
2045 );
2046 let outcome = push_local_actions(&local, &remote, &forced).expect("command force pushes");
2047
2048 assert_eq!(outcome.commands.len(), 1);
2049 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2050 assert_eq!(
2051 remote_refs
2052 .read_ref("refs/heads/main")
2053 .expect("remote ref should read"),
2054 Some(RefTarget::Direct(unrelated))
2055 );
2056 }
2057
2058 #[test]
2059 fn local_push_actions_command_force_is_precise_for_non_ff_validation() {
2060 let local = temp_repo("actions-command-force-precise-local");
2061 let remote = temp_repo("actions-command-force-precise-remote");
2062 let base = write_commit(&local, Vec::new(), "base");
2063 let remote_base = write_commit(&remote, Vec::new(), "base");
2064 assert_eq!(remote_base, base);
2065 let forced_unrelated = write_commit(&local, Vec::new(), "forced unrelated");
2066 let unforced_unrelated = write_commit(&local, Vec::new(), "unforced unrelated");
2067 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2068 set_ref(&remote, "refs/heads/topic", RefTarget::Direct(base));
2069 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2070 vec![
2071 PushCommand {
2072 src: Some(forced_unrelated),
2073 dst: "refs/heads/main".into(),
2074 expected_old: Some(base),
2075 force: true,
2076 },
2077 PushCommand {
2078 src: Some(unforced_unrelated),
2079 dst: "refs/heads/topic".into(),
2080 expected_old: Some(base),
2081 force: false,
2082 },
2083 ],
2084 default_options(),
2085 );
2086
2087 let err = push_local_actions(&local, &remote, &plan)
2088 .expect_err("only the forced command should bypass non-fast-forward validation");
2089
2090 assert!(err.to_string().contains("non-fast-forward update to topic"));
2091 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2092 assert_eq!(
2093 remote_refs
2094 .read_ref("refs/heads/main")
2095 .expect("remote ref should read"),
2096 Some(RefTarget::Direct(base))
2097 );
2098 assert_eq!(
2099 remote_refs
2100 .read_ref("refs/heads/topic")
2101 .expect("remote ref should read"),
2102 Some(RefTarget::Direct(base))
2103 );
2104 }
2105
2106 #[test]
2107 fn local_push_actions_stale_update_old_rejects_without_mutating() {
2108 let local = temp_repo("actions-stale-local");
2109 let remote = temp_repo("actions-stale-remote");
2110 let base = write_commit(&local, Vec::new(), "base");
2111 let remote_base = write_commit(&remote, Vec::new(), "base");
2112 assert_eq!(remote_base, base);
2113 let tip = write_commit(&local, vec![base], "tip");
2114 let concurrent = write_commit(&remote, vec![base], "concurrent");
2115 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2116 let plan = PushActionPlan::from_actions(
2117 vec![PushAction::Update {
2118 dst: "refs/heads/main".into(),
2119 old: base,
2120 new: tip,
2121 }],
2122 default_options(),
2123 );
2124
2125 let err = push_local_actions(&local, &remote, &plan).expect_err("stale old rejects");
2126
2127 assert!(err.to_string().contains("expected ref refs/heads/main"));
2128 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2129 assert_eq!(
2130 remote_refs
2131 .read_ref("refs/heads/main")
2132 .expect("remote ref should read"),
2133 Some(RefTarget::Direct(concurrent))
2134 );
2135 }
2136
2137 #[test]
2138 fn local_push_actions_stale_delete_old_rejects_without_mutating() {
2139 let local = temp_repo("actions-delete-local");
2140 let remote = temp_repo("actions-delete-remote");
2141 let base = write_commit(&local, Vec::new(), "base");
2142 let remote_base = write_commit(&remote, Vec::new(), "base");
2143 assert_eq!(remote_base, base);
2144 let concurrent = write_commit(&remote, vec![base], "concurrent");
2145 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2146 let plan = PushActionPlan::from_actions(
2147 vec![PushAction::Delete {
2148 dst: "refs/heads/main".into(),
2149 old: Some(base),
2150 }],
2151 default_options(),
2152 );
2153
2154 let err = push_local_actions(&local, &remote, &plan).expect_err("stale delete rejects");
2155
2156 assert!(err.to_string().contains("expected ref refs/heads/main"));
2157 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2158 assert_eq!(
2159 remote_refs
2160 .read_ref("refs/heads/main")
2161 .expect("remote ref should read"),
2162 Some(RefTarget::Direct(concurrent))
2163 );
2164 }
2165
2166 #[test]
2167 fn local_push_actions_create_rejects_existing_ref() {
2168 let local = temp_repo("actions-create-local");
2169 let remote = temp_repo("actions-create-remote");
2170 let base = write_commit(&local, Vec::new(), "base");
2171 let remote_base = write_commit(&remote, Vec::new(), "base");
2172 assert_eq!(remote_base, base);
2173 let tip = write_commit(&local, vec![base], "tip");
2174 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2175 let plan = PushActionPlan::from_actions(
2176 vec![PushAction::Create {
2177 dst: "refs/heads/main".into(),
2178 new: tip,
2179 }],
2180 default_options(),
2181 );
2182
2183 let err = push_local_actions(&local, &remote, &plan).expect_err("create must be absent");
2184
2185 assert!(
2186 err.to_string()
2187 .contains("expected ref refs/heads/main to not already exist")
2188 );
2189 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2190 assert_eq!(
2191 remote_refs
2192 .read_ref("refs/heads/main")
2193 .expect("remote ref should read"),
2194 Some(RefTarget::Direct(base))
2195 );
2196 }
2197
2198 #[test]
2199 fn report_status_rejection_is_an_error() {
2200 let report = ReceivePackReportStatus {
2201 unpack: ReceivePackUnpackStatus::Ok,
2202 commands: vec![ReceivePackCommandStatus::Ng {
2203 name: "refs/heads/main".into(),
2204 message: "hook declined".into(),
2205 }],
2206 };
2207
2208 let err = validate_receive_pack_report(&report).expect_err("ng report should fail");
2209
2210 assert!(err.to_string().contains("hook declined"));
2211 }
2212
2213 #[test]
2214 fn failed_local_push_does_not_partially_mutate_remote_ref() {
2215 let local = temp_repo("local-rejected");
2216 let remote = temp_repo("remote-rejected");
2217 let base = write_commit(&local, Vec::new(), "base");
2218 let planned = write_commit(&local, vec![base], "planned");
2219 let concurrent = write_commit(&local, vec![base], "concurrent");
2220 set_ref(&local, "refs/heads/main", RefTarget::Direct(planned));
2221 set_ref(
2222 &local,
2223 "HEAD",
2224 RefTarget::Symbolic("refs/heads/main".into()),
2225 );
2226 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2227 let destination = PushDestination::Local {
2228 git_dir: remote.clone(),
2229 common_git_dir: remote.clone(),
2230 };
2231 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
2232 let options = default_options();
2233 let request = PushRequest {
2234 git_dir: &local,
2235 common_git_dir: &local,
2236 format: ObjectFormat::Sha1,
2237 config: &GitConfig::default(),
2238 remote: "origin",
2239 destination: &destination,
2240 refspecs: &refspecs,
2241 options: &options,
2242 };
2243 let mut credentials = NoCredentials;
2244 let mut progress = SilentProgress;
2245 let mut services = PushServices {
2246 credentials: &mut credentials,
2247 progress: &mut progress,
2248 };
2249 let plan = plan_push(request, &mut services).expect("push should plan");
2250
2251 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2252 let _err = execute_push_plan(request, &mut services, plan)
2253 .expect_err("stale old id should reject the ref update");
2254
2255 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2256 assert_eq!(
2257 remote_refs
2258 .read_ref("refs/heads/main")
2259 .expect("remote ref should read"),
2260 Some(RefTarget::Direct(concurrent))
2261 );
2262 }
2263}