1use std::collections::HashMap;
22use std::fs;
23#[cfg(feature = "http")]
24use std::io::Read;
25use std::path::{Path, PathBuf};
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use sley_config::GitConfig;
29use sley_core::{GitError, ObjectFormat, ObjectId, Result};
30use sley_object::{Commit, ObjectType};
31use sley_odb::{
32 FileObjectDatabase, ObjectReader, RawPackInstallOptions, build_and_install_reachable_pack,
33 collect_reachable_object_ids,
34};
35#[cfg(feature = "http")]
36use sley_protocol::{
37 GitService, ReceivePackFeatures, ReceivePackPushRequestOptions, parse_receive_pack_features,
38 read_receive_pack_report_status, smart_http_rpc_request_content_type,
39 smart_http_rpc_result_content_type,
40};
41use sley_protocol::{
42 PushSourceRef, ReceivePackCommand, ReceivePackCommandStatus, ReceivePackPushRequest,
43 ReceivePackReportStatus, ReceivePackRequest, ReceivePackUnpackStatus, RefAdvertisement,
44 RefSpec, parse_refspec, plan_push_commands,
45};
46
47use crate::pack::push_pack_roots;
48#[cfg(feature = "http")]
49use crate::pack::{PushPackRequest, write_receive_pack_body};
50use sley_refs::{FileRefStore, Ref, RefTarget};
51use sley_transport::RemoteUrl;
52#[cfg(feature = "http")]
53use sley_transport::{HttpClient, HttpResponse, http_smart_rpc_url};
54
55use crate::{CredentialProvider, ProgressSink};
56
57pub enum PushDestination {
63 Http(RemoteUrl),
65 Ssh(RemoteUrl),
68 Git(RemoteUrl),
70 Local {
72 git_dir: PathBuf,
74 common_git_dir: PathBuf,
76 },
77}
78
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
81pub enum PushThinMode {
82 #[default]
85 Auto,
86 Always,
88 Never,
90}
91
92impl PushThinMode {
93 pub(crate) fn wants_thin(self) -> bool {
94 !matches!(self, Self::Never)
95 }
96}
97
98#[derive(Debug, Clone, Copy, Default)]
107pub struct PushOptions {
108 pub quiet: bool,
112 pub force: bool,
115 pub thin: PushThinMode,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct PushCommand {
122 pub src: Option<ObjectId>,
124 pub dst: String,
126 pub expected_old: Option<ObjectId>,
130 pub force: bool,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum PushAction {
139 Create {
140 dst: String,
141 new: ObjectId,
142 },
143 Update {
144 dst: String,
145 old: ObjectId,
146 new: ObjectId,
147 },
148 Delete {
149 dst: String,
150 old: Option<ObjectId>,
151 },
152}
153
154impl From<PushAction> for PushCommand {
155 fn from(value: PushAction) -> Self {
156 match value {
157 PushAction::Create { dst, new } => Self {
158 src: Some(new),
159 dst,
160 expected_old: None,
161 force: false,
162 },
163 PushAction::Update { dst, old, new } => Self {
164 src: Some(new),
165 dst,
166 expected_old: Some(old),
167 force: false,
168 },
169 PushAction::Delete { dst, old } => Self {
170 src: None,
171 dst,
172 expected_old: old,
173 force: false,
174 },
175 }
176 }
177}
178
179#[derive(Debug, Clone)]
182pub struct PushActionPlan {
183 pub commands: Vec<PushCommand>,
184 pub pack_objects: Vec<ObjectId>,
185 pub options: PushOptions,
186}
187
188impl PushActionPlan {
189 pub fn from_actions(actions: Vec<PushAction>, options: PushOptions) -> Self {
190 Self {
191 commands: actions.into_iter().map(PushCommand::from).collect(),
192 pack_objects: Vec::new(),
193 options,
194 }
195 }
196
197 pub fn from_commands(commands: Vec<PushCommand>, options: PushOptions) -> Self {
198 Self {
199 commands,
200 pack_objects: Vec::new(),
201 options,
202 }
203 }
204
205 pub fn from_commands_and_infer_pack_roots(
206 commands: Vec<PushCommand>,
207 options: PushOptions,
208 ) -> Self {
209 let mut pack_objects = Vec::new();
210 for command in &commands {
211 let Some(src) = command.src.as_ref() else {
212 continue;
213 };
214 if !pack_objects.contains(src) {
215 pack_objects.push(*src);
216 }
217 }
218 Self {
219 commands,
220 pack_objects,
221 options,
222 }
223 }
224}
225
226#[derive(Debug, Clone, Default)]
228pub struct PushOutcome {
229 pub commands: Vec<ReceivePackCommand>,
234 pub report: Option<ReceivePackReportStatus>,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum PushRefStatus {
247 Ok,
249 UpToDate,
251 RejectNonFastForward,
253 RejectFetchFirst,
255 RejectStale,
257 RejectRemoteUpdated,
259 RejectAlreadyExists,
261 RemoteReject(String),
263 AtomicPushFailed,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct PushReportRef {
273 pub src: Option<String>,
276 pub dst: String,
278 pub old_id: ObjectId,
280 pub new_id: ObjectId,
282 pub forced: bool,
284 pub status: PushRefStatus,
286}
287
288impl PushReportRef {
289 pub fn is_deletion(&self) -> bool {
291 self.new_id.is_null()
292 }
293
294 pub fn had_error(&self) -> bool {
297 !matches!(self.status, PushRefStatus::Ok | PushRefStatus::UpToDate)
298 }
299}
300
301#[derive(Debug, Clone, Default, PartialEq, Eq)]
305pub struct PushStatusReport {
306 pub refs: Vec<PushReportRef>,
308}
309
310impl PushStatusReport {
311 pub fn had_errors(&self) -> bool {
313 self.refs.iter().any(PushReportRef::had_error)
314 }
315
316 pub fn refs_pushed(&self) -> bool {
319 self.refs.iter().any(|reference| {
320 reference.old_id != reference.new_id && matches!(reference.status, PushRefStatus::Ok)
321 })
322 }
323}
324
325#[derive(Clone, Copy)]
327pub struct PushRequest<'a> {
328 pub git_dir: &'a Path,
330 pub common_git_dir: &'a Path,
332 pub format: ObjectFormat,
334 pub config: &'a GitConfig,
336 pub remote: &'a str,
338 pub destination: &'a PushDestination,
340 pub refspecs: &'a [String],
342 pub options: &'a PushOptions,
344}
345
346#[derive(Clone, Copy)]
348pub struct PushActionRequest<'a> {
349 pub git_dir: &'a Path,
351 pub common_git_dir: &'a Path,
353 pub format: ObjectFormat,
355 pub config: &'a GitConfig,
357 pub remote: &'a str,
359 pub destination: &'a PushDestination,
361 pub plan: &'a PushActionPlan,
363}
364
365pub struct PushServices<'a> {
367 pub credentials: &'a mut dyn CredentialProvider,
369 pub progress: &'a mut dyn ProgressSink,
371}
372
373pub struct PushPlan {
376 pub commands: Vec<ReceivePackCommand>,
378 execution: PushExecution,
379}
380
381enum PushExecution {
382 Noop,
383 #[cfg(feature = "http")]
384 Http {
385 remote_url: RemoteUrl,
386 features: ReceivePackFeatures,
387 advertisements: Vec<RefAdvertisement>,
388 pack_objects: Vec<ObjectId>,
389 },
390 Ssh(crate::ssh::SshPushPlan),
391 Git(crate::git::GitPushPlan),
392 Local {
393 remote_git_dir: PathBuf,
394 remote_common_git_dir: PathBuf,
395 remote_refs: Vec<RefAdvertisement>,
396 command_forces: Vec<(ReceivePackCommand, bool)>,
397 pack_objects: Vec<ObjectId>,
398 },
399}
400
401pub fn push(request: PushRequest<'_>, mut services: PushServices<'_>) -> Result<PushOutcome> {
416 let plan = plan_push(request, &mut services)?;
417 execute_push_plan(request, &mut services, plan)
418}
419
420pub fn push_actions(
422 request: PushActionRequest<'_>,
423 mut services: PushServices<'_>,
424) -> Result<PushOutcome> {
425 let plan = plan_push_actions(request, &mut services)?;
426 execute_push_action_plan(request, &mut services, plan)
427}
428
429pub fn plan_push(request: PushRequest<'_>, services: &mut PushServices<'_>) -> Result<PushPlan> {
432 let _ = &mut services.progress;
437 crate::protocol::check_transport_allowed(
438 scheme_for_push_destination(request.destination),
439 Some(request.config),
440 None,
441 )
442 .map_err(crate::protocol::transport_policy_git_error)?;
443 match request.destination {
444 #[cfg(feature = "http")]
445 PushDestination::Http(remote_url) => plan_push_http(PushHttpRequest {
446 git_dir: request.git_dir,
447 common_git_dir: request.common_git_dir,
448 format: request.format,
449 remote_url,
450 refspecs: request.refspecs,
451 options: request.options,
452 credentials: services.credentials,
453 }),
454 #[cfg(not(feature = "http"))]
455 PushDestination::Http(_) => Err(GitError::Unsupported(
456 "HTTP transport is not enabled in this build".into(),
457 )),
458 PushDestination::Ssh(remote_url) => {
459 let plan = crate::ssh::plan_push_ssh(crate::ssh::SshPushRequest {
460 git_dir: request.git_dir,
461 common_git_dir: request.common_git_dir,
462 format: request.format,
463 remote: remote_url,
464 refspecs: request.refspecs,
465 force: request.options.force,
466 })?;
467 let commands = plan.commands.clone();
468 let execution = if commands.is_empty() {
469 PushExecution::Noop
470 } else {
471 PushExecution::Ssh(plan)
472 };
473 Ok(PushPlan {
474 commands,
475 execution,
476 })
477 }
478 PushDestination::Git(remote_url) => {
479 let plan = crate::git::plan_push_git(crate::git::GitPushRequest {
480 git_dir: request.git_dir,
481 common_git_dir: request.common_git_dir,
482 format: request.format,
483 remote: remote_url,
484 refspecs: request.refspecs,
485 force: request.options.force,
486 })?;
487 let commands = plan.commands.clone();
488 let execution = if commands.is_empty() {
489 PushExecution::Noop
490 } else {
491 PushExecution::Git(plan)
492 };
493 Ok(PushPlan {
494 commands,
495 execution,
496 })
497 }
498 PushDestination::Local {
499 git_dir: remote_git_dir,
500 common_git_dir: remote_common_git_dir,
501 } => plan_push_local(PushLocalRequest {
502 git_dir: request.git_dir,
503 common_git_dir: request.common_git_dir,
504 format: request.format,
505 remote: request.remote,
506 remote_git_dir,
507 remote_common_git_dir,
508 refspecs: request.refspecs,
509 options: request.options,
510 }),
511 }
512}
513
514pub fn plan_push_actions(
517 request: PushActionRequest<'_>,
518 services: &mut PushServices<'_>,
519) -> Result<PushPlan> {
520 let _ = &mut services.progress;
521 crate::protocol::check_transport_allowed(
522 scheme_for_push_destination(request.destination),
523 Some(request.config),
524 None,
525 )
526 .map_err(crate::protocol::transport_policy_git_error)?;
527 let commands = receive_pack_commands_from_action_plan(request.format, request.plan)?;
528 let command_forces = commands
529 .iter()
530 .cloned()
531 .zip(request.plan.commands.iter())
532 .map(|(command, planned)| (command, request.plan.options.force || planned.force))
533 .collect::<Vec<_>>();
534 match request.destination {
535 #[cfg(feature = "http")]
536 PushDestination::Http(remote_url) => {
537 let client = crate::http::new_http_client();
538 let discovered = crate::http::http_service_advertisements(
539 &client,
540 remote_url,
541 request.format,
542 GitService::ReceivePack,
543 services.credentials,
544 )?;
545 let advertisement_set = discovered.set;
546 let features = advertised_receive_pack_features(&advertisement_set.refs)?;
547 verify_remote_object_format(&features, request.format)?;
548 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
549 reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
550 let execution = if commands.is_empty() {
551 PushExecution::Noop
552 } else {
553 PushExecution::Http {
554 remote_url: remote_url.clone(),
555 features,
556 advertisements: advertisement_set.refs,
557 pack_objects: request.plan.pack_objects.clone(),
558 }
559 };
560 Ok(PushPlan {
561 commands,
562 execution,
563 })
564 }
565 #[cfg(not(feature = "http"))]
566 PushDestination::Http(_) => Err(GitError::Unsupported(
567 "HTTP transport is not enabled in this build".into(),
568 )),
569 PushDestination::Ssh(remote_url) => {
570 let plan = crate::ssh::plan_push_ssh_commands(crate::ssh::SshPushCommandsRequest {
571 common_git_dir: request.common_git_dir,
572 format: request.format,
573 remote: remote_url,
574 command_forces: command_forces.clone(),
575 pack_objects: request.plan.pack_objects.clone(),
576 })?;
577 let commands = plan.commands.clone();
578 let execution = if commands.is_empty() {
579 PushExecution::Noop
580 } else {
581 PushExecution::Ssh(plan)
582 };
583 Ok(PushPlan {
584 commands,
585 execution,
586 })
587 }
588 PushDestination::Git(remote_url) => {
589 let plan = crate::git::plan_push_git_commands(crate::git::GitPushCommandsRequest {
590 common_git_dir: request.common_git_dir,
591 format: request.format,
592 remote: remote_url,
593 command_forces: command_forces.clone(),
594 pack_objects: request.plan.pack_objects.clone(),
595 })?;
596 let commands = plan.commands.clone();
597 let execution = if commands.is_empty() {
598 PushExecution::Noop
599 } else {
600 PushExecution::Git(plan)
601 };
602 Ok(PushPlan {
603 commands,
604 execution,
605 })
606 }
607 PushDestination::Local {
608 git_dir: remote_git_dir,
609 common_git_dir: remote_common_git_dir,
610 } => {
611 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
612 if remote_format != request.format {
613 return Err(GitError::InvalidObjectId(format!(
614 "remote repository uses {}, local repository uses {}",
615 remote_format.name(),
616 request.format.name()
617 )));
618 }
619 let remote_refs =
620 crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
621 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
622 reject_non_fast_forward_pushes(&local_db, request.format, &command_forces)?;
623 let execution = if commands.is_empty() {
624 PushExecution::Noop
625 } else {
626 PushExecution::Local {
627 remote_git_dir: remote_git_dir.to_path_buf(),
628 remote_common_git_dir: remote_common_git_dir.to_path_buf(),
629 remote_refs,
630 command_forces,
631 pack_objects: request.plan.pack_objects.clone(),
632 }
633 };
634 Ok(PushPlan {
635 commands,
636 execution,
637 })
638 }
639 }
640}
641
642fn scheme_for_push_destination(destination: &PushDestination) -> &'static str {
643 match destination {
644 PushDestination::Http(remote) => crate::protocol::transport_scheme_for_remote(remote),
645 PushDestination::Ssh(remote) => crate::protocol::transport_scheme_for_remote(remote),
646 PushDestination::Git(remote) => crate::protocol::transport_scheme_for_remote(remote),
647 PushDestination::Local { .. } => "file",
648 }
649}
650
651pub fn execute_push_plan(
653 request: PushRequest<'_>,
654 services: &mut PushServices<'_>,
655 plan: PushPlan,
656) -> Result<PushOutcome> {
657 let _ = (request.config, request.remote);
658 let _ = &mut services.progress;
659 if plan.commands.is_empty() {
660 return Ok(PushOutcome::default());
661 }
662 match plan.execution {
663 PushExecution::Noop => Ok(PushOutcome::default()),
664 #[cfg(feature = "http")]
665 PushExecution::Http {
666 remote_url,
667 features,
668 advertisements,
669 pack_objects,
670 } => execute_push_http(
671 request,
672 services.credentials,
673 plan.commands,
674 remote_url,
675 features,
676 advertisements,
677 pack_objects,
678 ),
679 PushExecution::Ssh(plan) => crate::ssh::execute_push_ssh_plan(request, plan),
680 PushExecution::Git(plan) => crate::git::execute_push_git_plan(request, plan),
681 PushExecution::Local {
682 remote_git_dir,
683 remote_common_git_dir,
684 remote_refs,
685 command_forces,
686 pack_objects,
687 } => execute_push_local(
688 request,
689 plan.commands,
690 remote_git_dir,
691 remote_common_git_dir,
692 remote_refs,
693 command_forces,
694 pack_objects,
695 ),
696 }
697}
698
699pub fn execute_push_action_plan(
701 request: PushActionRequest<'_>,
702 services: &mut PushServices<'_>,
703 plan: PushPlan,
704) -> Result<PushOutcome> {
705 let refspecs: &[String] = &[];
706 execute_push_plan(
707 PushRequest {
708 git_dir: request.git_dir,
709 common_git_dir: request.common_git_dir,
710 format: request.format,
711 config: request.config,
712 remote: request.remote,
713 destination: request.destination,
714 refspecs,
715 options: &request.plan.options,
716 },
717 services,
718 plan,
719 )
720}
721
722#[cfg(feature = "http")]
725struct PushHttpRequest<'a> {
726 git_dir: &'a Path,
727 common_git_dir: &'a Path,
728 format: ObjectFormat,
729 remote_url: &'a RemoteUrl,
730 refspecs: &'a [String],
731 options: &'a PushOptions,
732 credentials: &'a mut dyn CredentialProvider,
733}
734
735#[cfg(feature = "http")]
736fn plan_push_http(request: PushHttpRequest<'_>) -> Result<PushPlan> {
737 let PushHttpRequest {
738 git_dir,
739 common_git_dir,
740 format,
741 remote_url,
742 refspecs,
743 options,
744 credentials,
745 } = request;
746 let client = crate::http::new_http_client();
747 let discovered = crate::http::http_service_advertisements(
748 &client,
749 remote_url,
750 format,
751 GitService::ReceivePack,
752 credentials,
753 )?;
754 let advertisement_set = discovered.set;
755 let features = advertised_receive_pack_features(&advertisement_set.refs)?;
756 verify_remote_object_format(&features, format)?;
757
758 let local_store = FileRefStore::new(git_dir, format);
759 let mut local_refs = local_push_source_refs(&local_store, format)?;
760 add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
761 let command_forces = plan_push_command_forces(
762 format,
763 &local_refs,
764 &advertisement_set.refs,
765 refspecs,
766 options.force,
767 )?;
768 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
769 reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
770 let commands = commands_from_forces(&command_forces);
771 let execution = if commands.is_empty() {
772 PushExecution::Noop
773 } else {
774 PushExecution::Http {
775 remote_url: remote_url.clone(),
776 features,
777 advertisements: advertisement_set.refs,
778 pack_objects: Vec::new(),
779 }
780 };
781 Ok(PushPlan {
782 commands,
783 execution,
784 })
785}
786
787#[cfg(feature = "http")]
788fn execute_push_http(
789 request: PushRequest<'_>,
790 credentials: &mut dyn CredentialProvider,
791 commands: Vec<ReceivePackCommand>,
792 remote_url: RemoteUrl,
793 features: ReceivePackFeatures,
794 advertisements: Vec<RefAdvertisement>,
795 pack_objects: Vec<ObjectId>,
796) -> Result<PushOutcome> {
797 let client = crate::http::new_http_client();
798 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
799 let pack_request = PushPackRequest {
800 local_db: &local_db,
801 format: request.format,
802 commands: &commands,
803 pack_objects: &pack_objects,
804 remote_advertisements: &advertisements,
805 features: &features,
806 options: receive_pack_push_options(&features, request.format, request.options.quiet),
807 thin: request.options.thin.wants_thin(),
808 };
809 let url = http_smart_rpc_url(&remote_url, GitService::ReceivePack)?;
810 let content_type = smart_http_rpc_request_content_type(GitService::ReceivePack)?;
811 let post_buffer = http_post_buffer(request.config);
812 let mut response = crate::http::http_send_with_auth(&remote_url, credentials, |auth| {
813 let headers = crate::http::http_authorization_headers(auth);
814 send_receive_pack_body(
815 &client,
816 &url,
817 &content_type,
818 &headers,
819 &pack_request,
820 post_buffer,
821 )
822 })?;
823 crate::http::http_check_status(&response, &url)?;
824 crate::http::http_validate_content_type(
825 &response,
826 &smart_http_rpc_result_content_type(GitService::ReceivePack)?,
827 )?;
828
829 let report = if features.report_status {
830 let report = read_receive_pack_report_status(&mut response.body)?;
831 validate_receive_pack_report(&report)?;
832 Some(report)
833 } else {
834 let mut sink = Vec::new();
835 response.body.read_to_end(&mut sink)?;
836 None
837 };
838 Ok(PushOutcome { commands, report })
839}
840
841#[cfg(feature = "http")]
847fn http_post_buffer(config: &GitConfig) -> usize {
848 const DEFAULT_POST_BUFFER: usize = 1 << 20;
849 config
850 .get("http", None, "postBuffer")
851 .and_then(parse_post_buffer)
852 .filter(|bytes| *bytes > 0)
853 .unwrap_or(DEFAULT_POST_BUFFER)
854}
855
856#[cfg(feature = "http")]
859fn parse_post_buffer(raw: &str) -> Option<usize> {
860 let raw = raw.trim();
861 let (digits, multiplier) = match raw.as_bytes().last() {
862 Some(b'k' | b'K') => (&raw[..raw.len() - 1], 1024usize),
863 Some(b'm' | b'M') => (&raw[..raw.len() - 1], 1024 * 1024),
864 Some(b'g' | b'G') => (&raw[..raw.len() - 1], 1024 * 1024 * 1024),
865 _ => (raw, 1),
866 };
867 digits
868 .trim()
869 .parse::<usize>()
870 .ok()
871 .and_then(|value| value.checked_mul(multiplier))
872}
873
874#[cfg(feature = "http")]
880fn send_receive_pack_body(
881 client: &dyn HttpClient,
882 url: &str,
883 content_type: &str,
884 headers: &[(&str, &str)],
885 pack_request: &PushPackRequest<'_>,
886 post_buffer: usize,
887) -> Result<HttpResponse> {
888 std::thread::scope(|scope| {
889 let (mut reader, writer) = std::io::pipe().map_err(|err| GitError::Io(err.to_string()))?;
890 let generator = scope.spawn(move || -> Result<()> {
891 let mut writer = writer;
894 write_receive_pack_body(pack_request, &mut writer)
895 });
896
897 let mut probe = Vec::new();
900 read_up_to(&mut reader, post_buffer.saturating_add(1), &mut probe)?;
901
902 if probe.len() <= post_buffer {
903 join_pack_generator(generator)?;
907 client.post(url, content_type, headers, &probe)
908 } else {
909 let response = {
914 let mut body = std::io::Cursor::new(probe).chain(reader);
915 client.post_reader(url, content_type, headers, &mut body)
916 };
917 let generation = join_pack_generator(generator);
918 match response {
919 Ok(response) => Ok(response),
922 Err(transport) => match generation {
923 Err(generation) => Err(generation),
924 Ok(()) => Err(transport),
925 },
926 }
927 }
928 })
929}
930
931#[cfg(feature = "http")]
934fn join_pack_generator(handle: std::thread::ScopedJoinHandle<'_, Result<()>>) -> Result<()> {
935 match handle.join() {
936 Ok(result) => result,
937 Err(_) => Err(GitError::Io(
938 "receive-pack body generator thread panicked".to_string(),
939 )),
940 }
941}
942
943#[cfg(feature = "http")]
945fn read_up_to(reader: &mut impl Read, cap: usize, out: &mut Vec<u8>) -> Result<()> {
946 let mut chunk = [0u8; 8192];
947 while out.len() < cap {
948 let want = (cap - out.len()).min(chunk.len());
949 let read = reader
950 .read(&mut chunk[..want])
951 .map_err(|err| GitError::Io(err.to_string()))?;
952 if read == 0 {
953 break;
954 }
955 out.extend_from_slice(&chunk[..read]);
956 }
957 Ok(())
958}
959
960struct PushLocalRequest<'a> {
964 git_dir: &'a Path,
965 common_git_dir: &'a Path,
966 format: ObjectFormat,
967 remote: &'a str,
968 remote_git_dir: &'a Path,
969 remote_common_git_dir: &'a Path,
970 refspecs: &'a [String],
971 options: &'a PushOptions,
972}
973
974fn plan_push_local(request: PushLocalRequest<'_>) -> Result<PushPlan> {
975 let PushLocalRequest {
976 git_dir,
977 common_git_dir,
978 format,
979 remote,
980 remote_git_dir,
981 remote_common_git_dir,
982 refspecs,
983 options,
984 } = request;
985 let _ = remote;
986 let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
987 if remote_format != format {
988 return Err(GitError::InvalidObjectId(format!(
989 "remote repository uses {}, local repository uses {}",
990 remote_format.name(),
991 format.name()
992 )));
993 }
994
995 let local_store = FileRefStore::new(git_dir, format);
996 let mut local_refs = local_push_source_refs(&local_store, format)?;
997 add_revision_push_sources(git_dir, format, refspecs, &mut local_refs);
998 let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
999 let command_forces =
1000 plan_push_command_forces(format, &local_refs, &remote_refs, refspecs, options.force)?;
1001 let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
1002 reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
1003 let commands = commands_from_forces(&command_forces);
1004 let execution = if commands.is_empty() {
1005 PushExecution::Noop
1006 } else {
1007 PushExecution::Local {
1008 remote_git_dir: remote_git_dir.to_path_buf(),
1009 remote_common_git_dir: remote_common_git_dir.to_path_buf(),
1010 remote_refs,
1011 command_forces,
1012 pack_objects: Vec::new(),
1013 }
1014 };
1015 Ok(PushPlan {
1016 commands,
1017 execution,
1018 })
1019}
1020
1021fn execute_push_local(
1022 request: PushRequest<'_>,
1023 commands: Vec<ReceivePackCommand>,
1024 remote_git_dir: PathBuf,
1025 remote_common_git_dir: PathBuf,
1026 remote_refs: Vec<RefAdvertisement>,
1027 _command_forces: Vec<(ReceivePackCommand, bool)>,
1028 pack_objects: Vec<ObjectId>,
1029) -> Result<PushOutcome> {
1030 let remote_excluded_tips =
1031 remote_excluded_tip_roots(&remote_git_dir, request.format, &remote_refs)?;
1032 let starts = push_pack_roots(&commands, &pack_objects);
1033 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
1034 let remote_db = FileObjectDatabase::from_git_dir(&remote_common_git_dir, request.format);
1035 let remote_excluded =
1036 collect_reachable_object_ids(&remote_db, request.format, remote_excluded_tips)?;
1037
1038 if remote_transfer_fsck_objects(&remote_common_git_dir) {
1043 fsck_pushed_objects(&local_db, request.format, &starts, &remote_excluded)?;
1044 }
1045 let packfile = if starts.is_empty() {
1046 Vec::new()
1047 } else {
1048 b"PACK".to_vec()
1049 };
1050 let receive_request = ReceivePackPushRequest {
1051 commands: ReceivePackRequest {
1052 shallow: Vec::new(),
1053 commands: commands.clone(),
1054 capabilities: Vec::new(),
1055 },
1056 push_options: None,
1057 packfile,
1058 };
1059 let report = crate::local::receive_pack_reachable_pack_into_local_repository(
1060 &remote_git_dir,
1061 request.format,
1062 &receive_request,
1063 &local_db,
1064 starts,
1065 remote_excluded,
1066 )?;
1067 validate_receive_pack_report(&report)?;
1068 Ok(PushOutcome {
1069 commands,
1070 report: Some(report),
1071 })
1072}
1073
1074fn remote_transfer_fsck_objects(remote_common_git_dir: &Path) -> bool {
1077 GitConfig::read(remote_common_git_dir.join("config"))
1078 .ok()
1079 .and_then(|config| config.get_bool("transfer", None, "fsckObjects"))
1080 .unwrap_or(false)
1081}
1082
1083pub struct PushQuarantine {
1086 object_dir: PathBuf,
1087}
1088
1089impl PushQuarantine {
1090 pub fn object_dir(&self) -> &Path {
1091 &self.object_dir
1092 }
1093}
1094
1095impl Drop for PushQuarantine {
1096 fn drop(&mut self) {
1097 let _ = fs::remove_dir_all(&self.object_dir);
1098 }
1099}
1100
1101pub fn stage_local_push_quarantine(
1102 remote_git_dir: &Path,
1103 remote_common_git_dir: &Path,
1104 format: ObjectFormat,
1105 source_db: &FileObjectDatabase,
1106 commands: &[ReceivePackCommand],
1107) -> Result<Option<PushQuarantine>> {
1108 let starts = push_pack_roots(commands, &[]);
1109 if starts.is_empty() {
1110 return Ok(None);
1111 }
1112 let remote_refs = crate::local::local_fetch_advertisements(remote_git_dir, format)?;
1113 let remote_excluded_tips = remote_excluded_tip_roots(remote_git_dir, format, &remote_refs)?;
1114 let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, format);
1115 let remote_excluded = collect_reachable_object_ids(&remote_db, format, remote_excluded_tips)?;
1116 let object_dir = create_push_quarantine_object_dir(remote_common_git_dir)?;
1117 let quarantine_db = FileObjectDatabase::new(object_dir.clone(), format);
1118 let installed = match build_and_install_reachable_pack(
1119 source_db,
1120 &quarantine_db,
1121 format,
1122 starts,
1123 &remote_excluded,
1124 RawPackInstallOptions { promisor: false },
1125 ) {
1126 Ok(installed) => installed,
1127 Err(err) => {
1128 let _ = fs::remove_dir_all(&object_dir);
1129 return Err(err);
1130 }
1131 };
1132 if installed.is_none() {
1133 let _ = fs::remove_dir_all(&object_dir);
1134 return Ok(None);
1135 }
1136 Ok(Some(PushQuarantine { object_dir }))
1137}
1138
1139fn create_push_quarantine_object_dir(remote_common_git_dir: &Path) -> Result<PathBuf> {
1140 let objects_dir = remote_common_git_dir.join("objects");
1141 fs::create_dir_all(&objects_dir)?;
1142 let nanos = SystemTime::now()
1143 .duration_since(UNIX_EPOCH)
1144 .map(|duration| duration.as_nanos())
1145 .unwrap_or(0);
1146 for attempt in 0..100 {
1147 let object_dir = objects_dir.join(format!(
1148 "tmp_objdir-incoming-{}-{nanos}-{attempt}",
1149 std::process::id()
1150 ));
1151 match fs::create_dir(&object_dir) {
1152 Ok(()) => {
1153 fs::create_dir_all(object_dir.join("pack"))?;
1154 fs::create_dir_all(object_dir.join("info"))?;
1155 return Ok(object_dir);
1156 }
1157 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
1158 Err(err) => return Err(GitError::Io(err.to_string())),
1159 }
1160 }
1161 Err(GitError::Io(
1162 "could not create push quarantine object directory".into(),
1163 ))
1164}
1165
1166fn remote_excluded_tip_roots(
1167 remote_git_dir: &Path,
1168 format: ObjectFormat,
1169 remote_refs: &[RefAdvertisement],
1170) -> Result<Vec<ObjectId>> {
1171 let mut tips = remote_refs
1172 .iter()
1173 .map(|reference| reference.oid)
1174 .collect::<Vec<_>>();
1175 append_remote_alternate_ref_tips(remote_git_dir, format, &mut tips)?;
1176 Ok(tips)
1177}
1178
1179fn append_remote_alternate_ref_tips(
1180 remote_git_dir: &Path,
1181 format: ObjectFormat,
1182 tips: &mut Vec<ObjectId>,
1183) -> Result<()> {
1184 let alternates = remote_git_dir.join("objects/info/alternates");
1185 let Ok(text) = fs::read_to_string(alternates) else {
1186 return Ok(());
1187 };
1188 for raw in text.lines() {
1189 let line = raw.trim();
1190 if line.is_empty() || line.starts_with('#') {
1191 continue;
1192 }
1193 let objects_dir = if Path::new(line).is_absolute() {
1194 PathBuf::from(line)
1195 } else {
1196 remote_git_dir.join("objects").join(line)
1197 };
1198 let Some(alternate_git_dir) = objects_dir.parent() else {
1199 continue;
1200 };
1201 let store = FileRefStore::new(alternate_git_dir, format);
1202 let refs = match store.list_refs() {
1203 Ok(refs) => refs,
1204 Err(_) => continue,
1205 };
1206 for reference in refs {
1207 let Some((oid, _)) = resolve_for_each_ref_target(&store, &reference)? else {
1208 continue;
1209 };
1210 if !tips.contains(&oid) {
1211 tips.push(oid);
1212 }
1213 }
1214 }
1215 Ok(())
1216}
1217
1218fn fsck_pushed_objects(
1222 local_db: &FileObjectDatabase,
1223 format: ObjectFormat,
1224 starts: &[ObjectId],
1225 remote_excluded: &std::collections::HashSet<ObjectId>,
1226) -> Result<()> {
1227 if starts.is_empty() {
1228 return Ok(());
1229 }
1230 let new_objects: Vec<ObjectId> =
1231 collect_reachable_object_ids(local_db, format, starts.to_vec())?
1232 .into_iter()
1233 .filter(|oid| !remote_excluded.contains(oid))
1234 .collect();
1235 let report = sley_fsck::fsck_objects(local_db, format, [], new_objects);
1239 if report.is_ok() {
1240 return Ok(());
1241 }
1242 for issue in &report.issues {
1243 if issue.severity == sley_fsck::IssueSeverity::Error {
1244 eprintln!("fatal: {}", issue.message);
1245 }
1246 }
1247 Err(GitError::Exit(128))
1248}
1249
1250pub struct PushReportRequest<'a> {
1252 pub git_dir: &'a Path,
1254 pub common_git_dir: &'a Path,
1256 pub format: ObjectFormat,
1258 pub remote_git_dir: &'a Path,
1260 pub remote_common_git_dir: &'a Path,
1262 pub refspecs: &'a [String],
1264 pub force: bool,
1266 pub atomic: bool,
1268 pub dry_run: bool,
1270 pub force_with_lease: &'a [(String, Option<ObjectId>)],
1273 pub force_with_lease_default: bool,
1278 pub force_if_includes: bool,
1281 pub receive_config_overrides: &'a [(String, String)],
1284}
1285
1286pub fn push_local_with_report(
1294 request: PushReportRequest<'_>,
1295 _config: &GitConfig,
1296) -> Result<PushStatusReport> {
1297 let format = request.format;
1298 let remote_format = crate::object_format_for_git_dir(request.remote_common_git_dir)?;
1299 if remote_format != format {
1300 return Err(GitError::InvalidObjectId(format!(
1301 "remote repository uses {}, local repository uses {}",
1302 remote_format.name(),
1303 format.name()
1304 )));
1305 }
1306 let local_store = FileRefStore::new(request.git_dir, format);
1307 let mut local_refs = local_push_source_refs(&local_store, format)?;
1308 add_revision_push_sources(request.git_dir, format, request.refspecs, &mut local_refs);
1309 let remote_refs = crate::local::local_fetch_advertisements(request.remote_git_dir, format)?;
1310 let planned = plan_push_command_sources(
1311 format,
1312 &local_refs,
1313 &remote_refs,
1314 request.refspecs,
1315 request.force,
1316 )?;
1317 let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, format);
1318 let remote_config =
1319 sley_config::read_repo_config(request.remote_git_dir, None).unwrap_or_default();
1320
1321 let mut refs: Vec<PushReportRef> = Vec::new();
1324 for plan in &planned {
1325 let status = classify_push_command(
1326 &local_db,
1327 format,
1328 plan,
1329 &request,
1330 &remote_config,
1331 request.remote_git_dir,
1332 )?;
1333 let stale_lease_overridden = plan.force && lease_expectation_mismatch(&request, plan);
1336 let forced = matches!(status, PushRefStatus::Ok)
1337 && !plan.command.old_id.is_null()
1338 && !plan.command.new_id.is_null()
1339 && (stale_lease_overridden
1340 || if plan.command.name.starts_with("refs/heads/") {
1341 !is_fast_forward(
1342 &local_db,
1343 format,
1344 &plan.command.old_id,
1345 &plan.command.new_id,
1346 )?
1347 } else {
1348 plan.force
1349 });
1350 refs.push(PushReportRef {
1351 src: plan.source.clone(),
1352 dst: plan.command.name.clone(),
1353 old_id: plan.command.old_id,
1354 new_id: plan.command.new_id,
1355 forced,
1356 status,
1357 });
1358 }
1359
1360 let any_local_reject = refs.iter().any(|reference| {
1361 matches!(
1362 reference.status,
1363 PushRefStatus::RejectNonFastForward
1364 | PushRefStatus::RejectFetchFirst
1365 | PushRefStatus::RejectStale
1366 | PushRefStatus::RejectRemoteUpdated
1367 | PushRefStatus::RejectAlreadyExists
1368 )
1369 });
1370
1371 if request.atomic && any_local_reject {
1375 for reference in &mut refs {
1376 if matches!(reference.status, PushRefStatus::Ok) {
1377 reference.status = PushRefStatus::AtomicPushFailed;
1378 }
1379 }
1380 return Ok(PushStatusReport { refs });
1381 }
1382
1383 if request.dry_run {
1384 return Ok(PushStatusReport { refs });
1385 }
1386
1387 let send: Vec<ReceivePackCommand> = refs
1389 .iter()
1390 .filter(|reference| {
1391 matches!(reference.status, PushRefStatus::Ok) && reference.old_id != reference.new_id
1392 })
1393 .map(|reference| ReceivePackCommand {
1394 old_id: reference.old_id,
1395 new_id: reference.new_id,
1396 name: reference.dst.clone(),
1397 })
1398 .collect();
1399
1400 if !send.is_empty() {
1401 let remote_excluded_tips =
1402 remote_excluded_tip_roots(request.remote_git_dir, format, &remote_refs)?;
1403 let pack_objects: Vec<ObjectId> = Vec::new();
1404 let starts = push_pack_roots(&send, &pack_objects);
1405 let remote_db = FileObjectDatabase::from_git_dir(request.remote_common_git_dir, format);
1406 let remote_excluded =
1407 collect_reachable_object_ids(&remote_db, format, remote_excluded_tips)?;
1408 if remote_transfer_fsck_objects(request.remote_common_git_dir) {
1412 fsck_pushed_objects(&local_db, format, &starts, &remote_excluded)?;
1413 }
1414 let packfile = if starts.is_empty() {
1415 Vec::new()
1416 } else {
1417 b"PACK".to_vec()
1418 };
1419 let receive_request = ReceivePackPushRequest {
1420 commands: ReceivePackRequest {
1421 shallow: Vec::new(),
1422 commands: send.clone(),
1423 capabilities: Vec::new(),
1424 },
1425 push_options: None,
1426 packfile,
1427 };
1428 let report = crate::local::receive_pack_reachable_pack_into_local_repository(
1429 request.remote_git_dir,
1430 format,
1431 &receive_request,
1432 &local_db,
1433 starts,
1434 remote_excluded,
1435 )?;
1436 if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
1438 for reference in &mut refs {
1439 if matches!(reference.status, PushRefStatus::Ok) {
1440 reference.status =
1441 PushRefStatus::RemoteReject(format!("unpacker error: {message}"));
1442 }
1443 }
1444 }
1445 for command_status in &report.commands {
1446 if let ReceivePackCommandStatus::Ng { name, message } = command_status {
1447 for reference in &mut refs {
1448 if reference.dst == *name && matches!(reference.status, PushRefStatus::Ok) {
1449 reference.status = PushRefStatus::RemoteReject(message.clone());
1450 }
1451 }
1452 }
1453 }
1454 }
1455
1456 Ok(PushStatusReport { refs })
1457}
1458
1459fn classify_push_command(
1463 local_db: &FileObjectDatabase,
1464 format: ObjectFormat,
1465 plan: &PlannedPushCommand,
1466 request: &PushReportRequest<'_>,
1467 config: &GitConfig,
1468 remote_git_dir: &Path,
1469) -> Result<PushRefStatus> {
1470 let command = &plan.command;
1471
1472 if receive_ref_is_hidden(config, request.receive_config_overrides, &command.name) {
1473 let reason = if command.new_id.is_null() {
1474 "deny deleting a hidden ref"
1475 } else {
1476 "deny updating a hidden ref"
1477 };
1478 return Ok(PushRefStatus::RemoteReject(reason.to_string()));
1479 }
1480
1481 if command.old_id == command.new_id && !command.new_id.is_null() {
1484 return Ok(PushRefStatus::UpToDate);
1485 }
1486
1487 if command.new_id.is_null() && !command.old_id.is_null() {
1488 if receive_config_bool(config, request.receive_config_overrides, "denydeletes")
1489 .unwrap_or(false)
1490 {
1491 return Ok(PushRefStatus::RemoteReject(
1492 "deletion prohibited".to_string(),
1493 ));
1494 }
1495 if receive_denies_current_branch_delete(format, command, config, request, remote_git_dir)? {
1496 return Ok(PushRefStatus::RemoteReject(
1497 "deletion of the current branch prohibited".to_string(),
1498 ));
1499 }
1500 }
1501
1502 if command.name.starts_with("refs/heads/") && !command.new_id.is_null() {
1503 let object = local_db.read_object(&command.new_id)?;
1504 if object.object_type != ObjectType::Commit {
1505 return Ok(PushRefStatus::RemoteReject(
1506 "invalid new value provided".to_string(),
1507 ));
1508 }
1509 }
1510
1511 if let Some((_, expected)) = request
1515 .force_with_lease
1516 .iter()
1517 .find(|(dst, _)| *dst == command.name)
1518 {
1519 let actual = if command.old_id.is_null() {
1520 None
1521 } else {
1522 Some(command.old_id)
1523 };
1524 if *expected != actual {
1525 if plan.force {
1526 return Ok(PushRefStatus::Ok);
1527 }
1528 return Ok(PushRefStatus::RejectStale);
1529 }
1530 if request.force_if_includes
1531 && !command.old_id.is_null()
1532 && (command.new_id.is_null()
1533 || !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?)
1534 && force_if_includes_rejects(
1535 local_db,
1536 format,
1537 request.git_dir,
1538 &command.name,
1539 &command.old_id,
1540 )?
1541 {
1542 if plan.force {
1543 return Ok(PushRefStatus::Ok);
1544 }
1545 return Ok(PushRefStatus::RejectRemoteUpdated);
1546 }
1547 return Ok(PushRefStatus::Ok);
1549 }
1550
1551 if command.name.starts_with("refs/heads/")
1552 && !command.old_id.is_null()
1553 && !command.new_id.is_null()
1554 && !is_fast_forward(local_db, format, &command.old_id, &command.new_id)?
1555 && receive_config_bool(
1556 config,
1557 request.receive_config_overrides,
1558 "denynonfastforwards",
1559 )
1560 .unwrap_or(false)
1561 {
1562 return Ok(PushRefStatus::RemoteReject(format!(
1563 "denying non-fast-forward {} (you should pull first)",
1564 command.name
1565 )));
1566 }
1567
1568 if !plan.force
1571 && command.name.starts_with("refs/tags/")
1572 && !command.old_id.is_null()
1573 && !command.new_id.is_null()
1574 {
1575 return Ok(PushRefStatus::RejectAlreadyExists);
1576 }
1577
1578 if !plan.force
1579 && command.name.starts_with("refs/heads/")
1580 && !command.old_id.is_null()
1581 && !command.new_id.is_null()
1582 {
1583 if !local_db.contains(&command.old_id)? {
1584 return Ok(PushRefStatus::RejectFetchFirst);
1585 }
1586 if !is_fast_forward(local_db, format, &command.old_id, &command.new_id)? {
1587 return Ok(PushRefStatus::RejectNonFastForward);
1588 }
1589 }
1590
1591 if !request.dry_run && receive_denies_current_branch(format, command, config, remote_git_dir)? {
1592 return Ok(PushRefStatus::RemoteReject(
1593 "branch is currently checked out".to_string(),
1594 ));
1595 }
1596
1597 Ok(PushRefStatus::Ok)
1598}
1599
1600fn receive_ref_is_hidden(
1601 config: &GitConfig,
1602 overrides: &[(String, String)],
1603 refname: &str,
1604) -> bool {
1605 let mut hide_refs = Vec::new();
1606 hide_refs.extend(hidden_ref_values(config, "transfer", None));
1607 hide_refs.extend(hidden_ref_values(config, "receive", None));
1608 hide_refs.extend(
1609 overrides
1610 .iter()
1611 .filter(|(key, _)| key.eq_ignore_ascii_case("hiderefs"))
1612 .map(|(_, value)| trim_hidden_ref_pattern(value)),
1613 );
1614 ref_is_hidden_by_patterns(refname, &hide_refs)
1615}
1616
1617fn hidden_ref_values(config: &GitConfig, section: &str, subsection: Option<&str>) -> Vec<String> {
1618 config
1619 .get_all(section, subsection, "hiderefs")
1620 .into_iter()
1621 .flatten()
1622 .map(trim_hidden_ref_pattern)
1623 .collect()
1624}
1625
1626fn trim_hidden_ref_pattern(value: &str) -> String {
1627 value.trim_end_matches('/').to_string()
1628}
1629
1630fn ref_is_hidden_by_patterns(refname: &str, patterns: &[String]) -> bool {
1631 for pattern in patterns.iter().rev() {
1632 let mut pattern = pattern.as_str();
1633 let negated = pattern.strip_prefix('!').is_some();
1634 if negated {
1635 pattern = &pattern[1..];
1636 }
1637 if let Some(rest) = pattern.strip_prefix('^') {
1638 pattern = rest;
1639 }
1640 if hidden_ref_pattern_matches(refname, pattern) {
1641 return !negated;
1642 }
1643 }
1644 false
1645}
1646
1647fn hidden_ref_pattern_matches(refname: &str, pattern: &str) -> bool {
1648 refname
1649 .strip_prefix(pattern)
1650 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
1651}
1652
1653fn lease_expectation_mismatch(request: &PushReportRequest<'_>, plan: &PlannedPushCommand) -> bool {
1654 let command = &plan.command;
1655 let actual = if command.old_id.is_null() {
1656 None
1657 } else {
1658 Some(command.old_id)
1659 };
1660 request
1661 .force_with_lease
1662 .iter()
1663 .find(|(dst, _)| *dst == command.name)
1664 .is_some_and(|(_, expected)| *expected != actual)
1665}
1666
1667fn force_if_includes_rejects(
1668 db: &FileObjectDatabase,
1669 format: ObjectFormat,
1670 git_dir: &Path,
1671 local_ref: &str,
1672 remote_old: &ObjectId,
1673) -> Result<bool> {
1674 let store = FileRefStore::new(git_dir, format);
1675 let mut candidates = Vec::new();
1676 match store.read_ref(local_ref)? {
1677 Some(RefTarget::Direct(oid)) => candidates.push(oid),
1678 Some(RefTarget::Symbolic(target)) => {
1679 if let Some(RefTarget::Direct(oid)) = store.read_ref(&target)? {
1680 candidates.push(oid);
1681 }
1682 }
1683 None => return Ok(false),
1684 }
1685 for entry in store.read_reflog(local_ref)? {
1686 if !entry.new_oid.is_null() {
1687 candidates.push(entry.new_oid);
1688 }
1689 }
1690 candidates.sort();
1691 candidates.dedup();
1692 for candidate in candidates {
1693 if candidate == *remote_old {
1694 return Ok(false);
1695 }
1696 if let Ok(ancestors) = ancestor_depths(db, format, &candidate)
1697 && ancestors.contains_key(remote_old)
1698 {
1699 return Ok(false);
1700 }
1701 }
1702 Ok(true)
1703}
1704
1705fn receive_config_bool(
1706 config: &GitConfig,
1707 overrides: &[(String, String)],
1708 key: &str,
1709) -> Option<bool> {
1710 overrides
1711 .iter()
1712 .rev()
1713 .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
1714 .and_then(|(_, value)| sley_config::parse_config_bool(value))
1715 .or_else(|| config.get_bool("receive", None, key))
1716}
1717
1718fn receive_denies_current_branch(
1719 format: ObjectFormat,
1720 command: &ReceivePackCommand,
1721 config: &GitConfig,
1722 remote_git_dir: &Path,
1723) -> Result<bool> {
1724 if command.new_id.is_null() {
1725 return Ok(false);
1726 }
1727 if !command.name.starts_with("refs/heads/") {
1728 return Ok(false);
1729 }
1730 let deny = config
1731 .get("receive", None, "denycurrentbranch")
1732 .unwrap_or("refuse");
1733 let denies = matches!(
1734 deny.to_ascii_lowercase().as_str(),
1735 "true" | "yes" | "on" | "1" | "refuse"
1736 );
1737 if !denies {
1738 return Ok(false);
1739 }
1740 if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
1741 return Ok(false);
1742 }
1743 let store = FileRefStore::new(remote_git_dir, format);
1744 Ok(matches!(
1745 store.read_ref("HEAD")?,
1746 Some(RefTarget::Symbolic(target)) if target == command.name
1747 ))
1748}
1749
1750fn receive_targets_current_branch(
1751 format: ObjectFormat,
1752 command: &ReceivePackCommand,
1753 remote_git_dir: &Path,
1754) -> Result<bool> {
1755 if !command.name.starts_with("refs/heads/") {
1756 return Ok(false);
1757 }
1758 if sley_worktree::worktree_root_for_git_dir(remote_git_dir)?.is_none() {
1759 return Ok(false);
1760 }
1761 let store = FileRefStore::new(remote_git_dir, format);
1762 Ok(matches!(
1763 store.read_ref("HEAD")?,
1764 Some(RefTarget::Symbolic(target)) if target == command.name
1765 ))
1766}
1767
1768fn receive_denies_current_branch_delete(
1769 format: ObjectFormat,
1770 command: &ReceivePackCommand,
1771 config: &GitConfig,
1772 request: &PushReportRequest<'_>,
1773 remote_git_dir: &Path,
1774) -> Result<bool> {
1775 if !receive_targets_current_branch(format, command, remote_git_dir)? {
1776 return Ok(false);
1777 }
1778 let deny = request
1779 .receive_config_overrides
1780 .iter()
1781 .rev()
1782 .find(|(candidate, _)| candidate.eq_ignore_ascii_case("denydeletecurrent"))
1783 .map(|(_, value)| value.as_str())
1784 .or_else(|| config.get("receive", None, "denydeletecurrent"))
1785 .unwrap_or("refuse");
1786 Ok(!matches!(
1787 deny.to_ascii_lowercase().as_str(),
1788 "ignore" | "warn" | "false" | "no" | "off" | "0"
1789 ))
1790}
1791
1792pub(crate) fn is_fast_forward(
1795 db: &FileObjectDatabase,
1796 format: ObjectFormat,
1797 old: &ObjectId,
1798 new: &ObjectId,
1799) -> Result<bool> {
1800 let ancestors = ancestor_depths(db, format, new)?;
1801 Ok(ancestors.contains_key(old))
1802}
1803
1804#[cfg(feature = "http")]
1807fn advertised_receive_pack_features(
1808 advertisements: &[RefAdvertisement],
1809) -> Result<ReceivePackFeatures> {
1810 advertisements
1811 .first()
1812 .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
1813 .transpose()
1814 .map(Option::unwrap_or_default)
1815}
1816
1817#[cfg(feature = "http")]
1820fn verify_remote_object_format(features: &ReceivePackFeatures, format: ObjectFormat) -> Result<()> {
1821 if let Some(remote_format) = features.object_format {
1822 if remote_format != format {
1823 return Err(GitError::InvalidObjectId(format!(
1824 "remote repository uses {}, local repository uses {}",
1825 remote_format.name(),
1826 format.name()
1827 )));
1828 }
1829 } else if format != ObjectFormat::Sha1 {
1830 return Err(GitError::InvalidObjectId(format!(
1831 "remote repository did not advertise object-format for {} push",
1832 format.name()
1833 )));
1834 }
1835 Ok(())
1836}
1837
1838#[cfg(feature = "http")]
1843fn receive_pack_push_options(
1844 features: &ReceivePackFeatures,
1845 format: ObjectFormat,
1846 quiet: bool,
1847) -> ReceivePackPushRequestOptions {
1848 ReceivePackPushRequestOptions {
1849 report_status: features.report_status,
1850 ofs_delta: features.ofs_delta,
1851 quiet: quiet && features.quiet,
1852 object_format: features
1853 .object_format
1854 .filter(|_| format != ObjectFormat::Sha1),
1855 ..ReceivePackPushRequestOptions::default()
1856 }
1857}
1858
1859pub(crate) fn plan_push_command_forces(
1864 format: ObjectFormat,
1865 local_refs: &[PushSourceRef],
1866 remote_refs: &[RefAdvertisement],
1867 refspecs: &[String],
1868 force: bool,
1869) -> Result<Vec<(ReceivePackCommand, bool)>> {
1870 let parsed_refspecs = refspecs
1871 .iter()
1872 .map(|refspec| {
1873 let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
1874 parse_refspec(&normalized)
1875 })
1876 .collect::<Result<Vec<_>>>()?;
1877 let mut command_forces = Vec::new();
1878 for refspec in &parsed_refspecs {
1879 for command in plan_push_commands(
1880 format,
1881 local_refs,
1882 remote_refs,
1883 std::slice::from_ref(refspec),
1884 )? {
1885 command_forces.push((command, force || refspec.force));
1886 }
1887 }
1888 Ok(command_forces)
1889}
1890
1891struct PlannedPushCommand {
1894 command: ReceivePackCommand,
1895 force: bool,
1896 source: Option<String>,
1897}
1898
1899fn plan_push_command_sources(
1905 format: ObjectFormat,
1906 local_refs: &[PushSourceRef],
1907 remote_refs: &[RefAdvertisement],
1908 refspecs: &[String],
1909 force: bool,
1910) -> Result<Vec<PlannedPushCommand>> {
1911 let mut planned = Vec::new();
1912 for refspec in refspecs {
1913 let normalized = normalize_push_refspec_for_sources(refspec, local_refs, remote_refs)?;
1914 let parsed = parse_refspec(&normalized)?;
1915 let commands = plan_push_commands(
1916 format,
1917 local_refs,
1918 remote_refs,
1919 std::slice::from_ref(&parsed),
1920 )?;
1921 for command in commands {
1922 let source = push_command_source_name(&parsed, &command);
1923 planned.push(PlannedPushCommand {
1924 command,
1925 force: force || parsed.force,
1926 source,
1927 });
1928 }
1929 }
1930 Ok(planned)
1931}
1932
1933fn push_command_source_name(refspec: &RefSpec, command: &ReceivePackCommand) -> Option<String> {
1938 let src = refspec.src.as_deref()?;
1939 if !refspec.pattern {
1940 return Some(src.to_string());
1941 }
1942 let (src_prefix, src_suffix) = src.split_once('*')?;
1943 let dst = refspec.dst.as_deref()?;
1944 let (dst_prefix, dst_suffix) = dst.split_once('*')?;
1945 let stem = command
1946 .name
1947 .strip_prefix(dst_prefix)
1948 .and_then(|rest| rest.strip_suffix(dst_suffix))?;
1949 Some(format!("{src_prefix}{stem}{src_suffix}"))
1950}
1951
1952pub(crate) fn add_revision_push_sources(
1953 git_dir: &Path,
1954 format: ObjectFormat,
1955 refspecs: &[String],
1956 local_refs: &mut Vec<PushSourceRef>,
1957) {
1958 for refspec in refspecs {
1959 let refspec = refspec.strip_prefix('+').unwrap_or(refspec);
1960 let src = refspec.split_once(':').map_or(refspec, |(src, _)| src);
1961 if src.is_empty() || src == "HEAD" {
1962 continue;
1963 }
1964 if src.starts_with("refs/") && local_refs.iter().any(|reference| reference.name == src) {
1965 continue;
1966 }
1967 if local_refs.iter().any(|reference| {
1968 reference.name == src
1969 || reference.name == format!("refs/heads/{src}")
1970 || reference.name == format!("refs/tags/{src}")
1971 }) {
1972 continue;
1973 }
1974 if let Ok(oid) = sley_rev::resolve_revision(git_dir, format, src)
1975 && !local_refs.iter().any(|reference| reference.name == src)
1976 {
1977 local_refs.push(PushSourceRef {
1978 name: src.to_string(),
1979 oid,
1980 });
1981 }
1982 }
1983}
1984
1985fn normalize_push_refspec_for_sources(
1986 refspec: &str,
1987 local_refs: &[PushSourceRef],
1988 remote_refs: &[RefAdvertisement],
1989) -> Result<String> {
1990 let (force, refspec) = refspec
1991 .strip_prefix('+')
1992 .map_or((false, refspec), |refspec| (true, refspec));
1993 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
1994 let (src, src_kind) = normalize_push_source_refname(src, local_refs);
1995 let dst = if src.is_empty() {
1996 normalize_push_delete_destination_refname(dst, remote_refs)?
1997 } else {
1998 normalize_push_destination_refname(dst, src_kind, remote_refs)?
1999 };
2000 if !src.is_empty() && !dst.contains('*') && push_destination_is_onelevel_under_refs(&dst) {
2001 return Err(GitError::Command(format!(
2002 "destination refspec {dst} is not a valid ref"
2003 )));
2004 }
2005 format!("{src}:{dst}")
2006 } else {
2007 let (name, _) = normalize_push_source_refname(refspec, local_refs);
2008 let dst = match count_refspec_match_dst(&name, remote_refs) {
2015 DstMatch::Unique(matched) => matched.to_string(),
2016 DstMatch::None => name.clone(),
2017 DstMatch::Ambiguous => {
2018 return Err(GitError::Command(format!(
2019 "dst refspec {name} matches more than one"
2020 )));
2021 }
2022 };
2023 format!("{name}:{dst}")
2024 };
2025 Ok(if force {
2026 format!("+{normalized}")
2027 } else {
2028 normalized
2029 })
2030}
2031
2032fn refname_match_rank(abbrev: &str, full_name: &str) -> Option<usize> {
2036 const RULES: [&str; 6] = [
2037 "{}",
2038 "refs/{}",
2039 "refs/tags/{}",
2040 "refs/heads/{}",
2041 "refs/remotes/{}",
2042 "refs/remotes/{}/HEAD",
2043 ];
2044 for (idx, rule) in RULES.iter().enumerate() {
2045 let (prefix, suffix) = rule.split_once("{}").unwrap_or((rule, ""));
2046 if full_name == format!("{prefix}{abbrev}{suffix}") {
2047 return Some(RULES.len() - idx);
2048 }
2049 }
2050 None
2051}
2052
2053enum DstMatch<'a> {
2055 Unique(&'a str),
2057 None,
2059 Ambiguous,
2061}
2062
2063fn count_refspec_match_dst<'a>(pattern: &str, remote_refs: &'a [RefAdvertisement]) -> DstMatch<'a> {
2070 let patlen = pattern.len();
2071 let mut strong: Option<&str> = None;
2072 let mut strong_count = 0usize;
2073 let mut weak: Option<&str> = None;
2074 let mut weak_count = 0usize;
2075 for advert in remote_refs {
2076 let name = advert.name.as_str();
2077 if refname_match_rank(pattern, name).is_none() {
2078 continue;
2079 }
2080 let namelen = name.len();
2081 let is_weak = namelen != patlen
2082 && patlen + 5 != namelen
2083 && !name.starts_with("refs/heads/")
2084 && !name.starts_with("refs/tags/");
2085 if is_weak {
2086 weak = Some(name);
2087 weak_count += 1;
2088 } else {
2089 strong = Some(name);
2090 strong_count += 1;
2091 }
2092 }
2093 match (strong_count, weak_count, strong, weak) {
2094 (1, _, Some(matched), _) => DstMatch::Unique(matched),
2095 (0, 1, _, Some(matched)) => DstMatch::Unique(matched),
2096 (0, 0, _, _) => DstMatch::None,
2097 _ => DstMatch::Ambiguous,
2098 }
2099}
2100
2101#[derive(Clone, Copy)]
2102enum PushSourceKind {
2103 Branch,
2104 Tag,
2105 Other,
2109 Unqualifiable,
2113}
2114
2115fn normalize_push_source_refname(
2116 name: &str,
2117 local_refs: &[PushSourceRef],
2118) -> (String, PushSourceKind) {
2119 if name.is_empty() || name == "HEAD" || name == "@" || name.starts_with("refs/") {
2122 return (name.to_string(), PushSourceKind::Other);
2123 }
2124 let branch = format!("refs/heads/{name}");
2125 let tag = format!("refs/tags/{name}");
2126 let has_branch = local_refs.iter().any(|reference| reference.name == branch);
2127 let has_tag = local_refs.iter().any(|reference| reference.name == tag);
2128 if has_tag && !has_branch {
2129 (tag, PushSourceKind::Tag)
2130 } else if has_branch {
2131 (branch, PushSourceKind::Branch)
2132 } else if local_refs.iter().any(|reference| reference.name == name) {
2133 (name.to_string(), PushSourceKind::Unqualifiable)
2137 } else {
2138 (branch, PushSourceKind::Branch)
2139 }
2140}
2141
2142fn normalize_push_delete_destination_refname(
2143 name: &str,
2144 remote_refs: &[RefAdvertisement],
2145) -> Result<String> {
2146 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
2147 return Ok(name.to_string());
2148 }
2149 match count_refspec_match_dst(name, remote_refs) {
2150 DstMatch::Unique(matched) => Ok(matched.to_string()),
2151 DstMatch::Ambiguous => Err(GitError::Command(format!(
2152 "dst refspec {name} matches more than one"
2153 ))),
2154 DstMatch::None => Err(GitError::reference_not_found(format!("remote ref {name}"))),
2155 }
2156}
2157
2158fn normalize_push_destination_refname(
2159 name: &str,
2160 src_kind: PushSourceKind,
2161 remote_refs: &[RefAdvertisement],
2162) -> Result<String> {
2163 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
2164 return Ok(name.to_string());
2165 }
2166 match count_refspec_match_dst(name, remote_refs) {
2172 DstMatch::Unique(matched) => Ok(matched.to_string()),
2173 DstMatch::Ambiguous => Err(GitError::Command(format!(
2174 "dst refspec {name} matches more than one"
2175 ))),
2176 DstMatch::None => match src_kind {
2177 PushSourceKind::Tag => Ok(format!("refs/tags/{name}")),
2178 PushSourceKind::Branch | PushSourceKind::Other => Ok(format!("refs/heads/{name}")),
2179 PushSourceKind::Unqualifiable => Err(GitError::Command(format!(
2183 "the destination you provided is not a full refname (i.e., starting with \"refs/\"); unable to guess the destination for {name}"
2184 ))),
2185 },
2186 }
2187}
2188
2189fn push_destination_is_onelevel_under_refs(name: &str) -> bool {
2190 name.strip_prefix("refs/")
2191 .is_some_and(|rest| !rest.contains('/'))
2192}
2193
2194fn commands_from_forces(command_forces: &[(ReceivePackCommand, bool)]) -> Vec<ReceivePackCommand> {
2196 command_forces
2197 .iter()
2198 .map(|(command, _)| command.clone())
2199 .collect()
2200}
2201
2202fn receive_pack_commands_from_action_plan(
2203 format: ObjectFormat,
2204 plan: &PushActionPlan,
2205) -> Result<Vec<ReceivePackCommand>> {
2206 let zero = ObjectId::null(format);
2207 for oid in &plan.pack_objects {
2208 if oid.format() != format {
2209 return Err(GitError::InvalidObjectId(format!(
2210 "push pack object {oid} has {} object id for {} repository",
2211 oid.format().name(),
2212 format.name()
2213 )));
2214 }
2215 }
2216 plan.commands
2217 .iter()
2218 .map(|command| {
2219 let old_id = command.expected_old.unwrap_or(zero);
2220 let new_id = command.src.unwrap_or(zero);
2221 if old_id.format() != format {
2222 return Err(GitError::InvalidObjectId(format!(
2223 "push command {} expected old has {} object id for {} repository",
2224 command.dst,
2225 old_id.format().name(),
2226 format.name()
2227 )));
2228 }
2229 if new_id.format() != format {
2230 return Err(GitError::InvalidObjectId(format!(
2231 "push command {} new id has {} object id for {} repository",
2232 command.dst,
2233 new_id.format().name(),
2234 format.name()
2235 )));
2236 }
2237 Ok(ReceivePackCommand {
2238 old_id,
2239 new_id,
2240 name: command.dst.clone(),
2241 })
2242 })
2243 .collect()
2244}
2245
2246pub fn validate_receive_pack_report(report: &ReceivePackReportStatus) -> Result<()> {
2249 if let ReceivePackUnpackStatus::Error(message) = &report.unpack {
2250 return Err(GitError::Command(format!(
2251 "failed to push some refs: unpack failed: {message}"
2252 )));
2253 }
2254 for status in &report.commands {
2255 if let ReceivePackCommandStatus::Ng { name, message } = status {
2256 return Err(GitError::Command(format!(
2257 "failed to push {name}: {message}"
2258 )));
2259 }
2260 }
2261 Ok(())
2262}
2263
2264pub fn local_push_source_refs(
2268 store: &FileRefStore,
2269 format: ObjectFormat,
2270) -> Result<Vec<PushSourceRef>> {
2271 let mut refs = Vec::new();
2272 for reference in store.list_refs()? {
2273 let Some((oid, _)) = resolve_for_each_ref_target(store, &reference)? else {
2274 continue;
2275 };
2276 if oid.format() != format {
2277 return Err(GitError::InvalidObjectId(format!(
2278 "local ref {} has {} object id for {} repository",
2279 reference.name,
2280 oid.format().name(),
2281 format.name()
2282 )));
2283 }
2284 refs.push(PushSourceRef {
2285 name: reference.name.clone(),
2286 oid,
2287 });
2288 if let Some(short) = reference.name.strip_prefix("refs/heads/") {
2289 refs.push(PushSourceRef {
2290 name: short.to_string(),
2291 oid,
2292 });
2293 }
2294 if let Some(short) = reference.name.strip_prefix("refs/tags/") {
2295 refs.push(PushSourceRef {
2296 name: short.to_string(),
2297 oid,
2298 });
2299 }
2300 }
2301 if let Some(target) = store.read_ref("HEAD")? {
2302 let head = Ref {
2303 name: "HEAD".to_string(),
2304 target,
2305 };
2306 if let Some((oid, _)) = resolve_for_each_ref_target(store, &head)?
2307 && oid.format() == format
2308 {
2309 refs.push(PushSourceRef {
2310 name: "HEAD".to_string(),
2311 oid,
2312 });
2313 }
2314 }
2315 Ok(refs)
2316}
2317
2318pub fn normalize_push_refspec(refspec: &str) -> String {
2322 let (force, refspec) = refspec
2323 .strip_prefix('+')
2324 .map_or((false, refspec), |refspec| (true, refspec));
2325 let normalized = if let Some((src, dst)) = refspec.split_once(':') {
2326 let src = normalize_push_refname(src);
2327 let dst = normalize_push_refname(dst);
2328 format!("{src}:{dst}")
2329 } else {
2330 let name = normalize_push_refname(refspec);
2331 format!("{name}:{name}")
2332 };
2333 if force {
2334 format!("+{normalized}")
2335 } else {
2336 normalized
2337 }
2338}
2339
2340pub fn normalize_push_refname(name: &str) -> String {
2343 if name.is_empty() || name == "HEAD" || name.starts_with("refs/") {
2344 name.to_string()
2345 } else {
2346 format!("refs/heads/{name}")
2347 }
2348}
2349
2350pub fn reject_non_fast_forward_pushes(
2354 local_db: &FileObjectDatabase,
2355 format: ObjectFormat,
2356 command_forces: &[(ReceivePackCommand, bool)],
2357) -> Result<()> {
2358 for (command, force) in command_forces {
2359 if *force
2360 || !command.name.starts_with("refs/heads/")
2361 || command.old_id.is_null()
2362 || command.new_id.is_null()
2363 {
2364 continue;
2365 }
2366 let ancestors = ancestor_depths(local_db, format, &command.new_id)?;
2367 if !ancestors.contains_key(&command.old_id) {
2368 let short = command.name.trim_start_matches("refs/heads/");
2369 return Err(GitError::Command(format!(
2370 "failed to push some refs: non-fast-forward update to {short}"
2371 )));
2372 }
2373 }
2374 Ok(())
2375}
2376
2377fn ancestor_depths(
2381 db: &FileObjectDatabase,
2382 format: ObjectFormat,
2383 start: &ObjectId,
2384) -> Result<HashMap<ObjectId, usize>> {
2385 let mut depths = HashMap::new();
2386 let mut pending = std::collections::VecDeque::from([(start.clone(), 0usize)]);
2387 while let Some((oid, depth)) = pending.pop_front() {
2388 if depths.get(&oid).is_some_and(|existing| *existing <= depth) {
2389 continue;
2390 }
2391 depths.insert(oid, depth);
2392 let object = db.read_object(&oid)?;
2393 if object.object_type != ObjectType::Commit {
2394 return Err(GitError::InvalidObject(format!(
2395 "expected commit {oid}, found {}",
2396 object.object_type.as_str()
2397 )));
2398 }
2399 let commit = Commit::parse_ref(format, &object.body)?;
2400 for parent in commit.parents {
2401 pending.push_back((parent, depth + 1));
2402 }
2403 }
2404 Ok(depths)
2405}
2406
2407fn resolve_for_each_ref_target(
2410 store: &FileRefStore,
2411 reference: &Ref,
2412) -> Result<Option<(ObjectId, Option<String>)>> {
2413 let mut target = reference.target.clone();
2414 let mut symref = None;
2415 for _ in 0..5 {
2416 match target {
2417 RefTarget::Direct(oid) => return Ok(Some((oid, symref))),
2418 RefTarget::Symbolic(name) => {
2419 symref.get_or_insert_with(|| name.clone());
2420 let Some(next) = store.read_ref(&name)? else {
2421 return Ok(None);
2422 };
2423 target = next;
2424 }
2425 }
2426 }
2427 Ok(None)
2428}
2429
2430#[cfg(test)]
2431mod tests {
2432 use super::*;
2433 use std::fs;
2434 use std::sync::atomic::{AtomicU64, Ordering};
2435
2436 use sley_formats::RepositoryLayout;
2437 use sley_object::{Commit, EncodedObject, ObjectType, Tree};
2438 use sley_odb::{FileObjectDatabase, ObjectWriter};
2439 use sley_protocol::{ReceivePackCommandStatus, ReceivePackUnpackStatus};
2440 use sley_refs::{RefTarget, RefUpdate};
2441
2442 use crate::{NoCredentials, SilentProgress};
2443
2444 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
2445
2446 fn temp_repo(name: &str) -> PathBuf {
2447 let dir = std::env::temp_dir().join(format!(
2448 "sley-remote-push-{name}-{}-{}",
2449 std::process::id(),
2450 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
2451 ));
2452 let _ = fs::remove_dir_all(&dir);
2453 RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
2454 .expect("test repository should initialize");
2455 dir.join(".git")
2456 }
2457
2458 fn write_commit(git_dir: &Path, parents: Vec<ObjectId>, message: &str) -> ObjectId {
2459 let format = ObjectFormat::Sha1;
2460 let db = FileObjectDatabase::from_git_dir(git_dir, format);
2461 let tree = db
2462 .write_object(EncodedObject::new(
2463 ObjectType::Tree,
2464 Tree { entries: vec![] }.write(),
2465 ))
2466 .expect("tree should write");
2467 let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
2468 db.write_object(EncodedObject::new(
2469 ObjectType::Commit,
2470 Commit {
2471 tree,
2472 parents,
2473 author: identity.clone(),
2474 committer: identity,
2475 encoding: None,
2476 message: format!("{message}\n").into_bytes(),
2477 }
2478 .write(),
2479 ))
2480 .expect("commit should write")
2481 }
2482
2483 fn set_ref(git_dir: &Path, name: &str, target: RefTarget) {
2484 let store = FileRefStore::new(git_dir, ObjectFormat::Sha1);
2485 let mut tx = store.transaction();
2486 tx.update(RefUpdate {
2487 name: name.to_string(),
2488 expected: None,
2489 new: target,
2490 reflog: None,
2491 });
2492 tx.commit().expect("ref should update");
2493 }
2494
2495 fn default_options() -> PushOptions {
2496 PushOptions {
2497 quiet: true,
2498 force: false,
2499 thin: PushThinMode::Auto,
2500 }
2501 }
2502
2503 #[derive(Default)]
2507 struct RecordingClient {
2508 last: std::sync::Mutex<Option<(&'static str, Vec<u8>)>>,
2509 }
2510
2511 impl RecordingClient {
2512 fn take(&self) -> (&'static str, Vec<u8>) {
2513 self.last
2514 .lock()
2515 .expect("lock")
2516 .take()
2517 .expect("a send was recorded")
2518 }
2519
2520 fn ok_response() -> Result<HttpResponse> {
2521 Ok(HttpResponse {
2522 status: 200,
2523 content_type: None,
2524 body: Box::new(std::io::empty()),
2525 })
2526 }
2527 }
2528
2529 impl HttpClient for RecordingClient {
2530 fn get(&self, _url: &str, _headers: &[(&str, &str)]) -> Result<HttpResponse> {
2531 Self::ok_response()
2532 }
2533
2534 fn post(
2535 &self,
2536 _url: &str,
2537 _content_type: &str,
2538 _headers: &[(&str, &str)],
2539 body: &[u8],
2540 ) -> Result<HttpResponse> {
2541 *self.last.lock().expect("lock") = Some(("post", body.to_vec()));
2542 Self::ok_response()
2543 }
2544
2545 fn post_reader(
2546 &self,
2547 _url: &str,
2548 _content_type: &str,
2549 _headers: &[(&str, &str)],
2550 body: &mut dyn Read,
2551 ) -> Result<HttpResponse> {
2552 let mut buffered = Vec::new();
2553 body.read_to_end(&mut buffered)
2554 .map_err(|err| GitError::Io(err.to_string()))?;
2555 *self.last.lock().expect("lock") = Some(("post_reader", buffered));
2556 Self::ok_response()
2557 }
2558 }
2559
2560 fn receive_pack_request<'a>(
2561 db: &'a FileObjectDatabase,
2562 commands: &'a [ReceivePackCommand],
2563 advertisements: &'a [RefAdvertisement],
2564 features: &'a ReceivePackFeatures,
2565 ) -> PushPackRequest<'a> {
2566 PushPackRequest {
2567 local_db: db,
2568 format: ObjectFormat::Sha1,
2569 commands,
2570 pack_objects: &[],
2571 remote_advertisements: advertisements,
2572 features,
2573 options: ReceivePackPushRequestOptions {
2574 report_status: true,
2575 ofs_delta: true,
2576 ..ReceivePackPushRequestOptions::default()
2577 },
2578 thin: false,
2579 }
2580 }
2581
2582 #[test]
2583 fn send_receive_pack_body_gates_on_post_buffer_and_preserves_bytes() {
2584 let git_dir = temp_repo("send-receive-pack-gate");
2585 let commit = write_commit(&git_dir, vec![], "streamed http push");
2586 let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
2587 let commands = [ReceivePackCommand {
2588 old_id: ObjectId::null(ObjectFormat::Sha1),
2589 new_id: commit,
2590 name: "refs/heads/main".into(),
2591 }];
2592 let features = ReceivePackFeatures {
2593 report_status: true,
2594 ofs_delta: true,
2595 ..ReceivePackFeatures::default()
2596 };
2597 let req = receive_pack_request(&db, &commands, &[], &features);
2598
2599 let mut canonical = Vec::new();
2601 write_receive_pack_body(&req, &mut canonical).expect("canonical body");
2602 assert!(canonical.len() > 1, "body should be non-trivial");
2603
2604 let buffered_client = RecordingClient::default();
2606 send_receive_pack_body(
2607 &buffered_client,
2608 "http://h/git-receive-pack",
2609 "ct",
2610 &[],
2611 &req,
2612 usize::MAX,
2613 )
2614 .expect("buffered send");
2615 let (method, body) = buffered_client.take();
2616 assert_eq!(method, "post");
2617 assert_eq!(body, canonical);
2618
2619 let streamed_client = RecordingClient::default();
2623 send_receive_pack_body(
2624 &streamed_client,
2625 "http://h/git-receive-pack",
2626 "ct",
2627 &[],
2628 &req,
2629 8,
2630 )
2631 .expect("streamed send");
2632 let (method, body) = streamed_client.take();
2633 assert_eq!(method, "post_reader");
2634 assert_eq!(body, canonical);
2635
2636 let _ = fs::remove_dir_all(git_dir.parent().unwrap_or(&git_dir));
2637 }
2638
2639 #[test]
2640 fn parse_post_buffer_reads_git_size_values() {
2641 assert_eq!(parse_post_buffer("1048576"), Some(1 << 20));
2642 assert_eq!(parse_post_buffer("512k"), Some(512 * 1024));
2643 assert_eq!(parse_post_buffer("1M"), Some(1024 * 1024));
2644 assert_eq!(parse_post_buffer("2g"), Some(2 * 1024 * 1024 * 1024));
2645 assert_eq!(parse_post_buffer(" 64k "), Some(64 * 1024));
2646 assert_eq!(parse_post_buffer("garbage"), None);
2647 assert_eq!(parse_post_buffer(""), None);
2648 }
2649
2650 #[test]
2651 fn push_action_plan_infers_pack_roots_from_non_delete_commands() {
2652 let repo = temp_repo("action-plan-infer-roots");
2653 let first = write_commit(&repo, Vec::new(), "first");
2654 let second = write_commit(&repo, vec![first], "second");
2655
2656 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2657 vec![
2658 PushCommand {
2659 src: Some(first),
2660 dst: "refs/heads/main".into(),
2661 expected_old: None,
2662 force: false,
2663 },
2664 PushCommand {
2665 src: Some(second),
2666 dst: "refs/heads/topic".into(),
2667 expected_old: Some(first),
2668 force: true,
2669 },
2670 ],
2671 default_options(),
2672 );
2673
2674 assert_eq!(plan.pack_objects, vec![first, second]);
2675 assert!(!plan.commands[0].force);
2676 assert!(plan.commands[1].force);
2677 }
2678
2679 #[test]
2680 fn push_action_plan_inferred_pack_roots_exclude_deletes() {
2681 let repo = temp_repo("action-plan-delete-roots");
2682 let old = write_commit(&repo, Vec::new(), "old");
2683 let new = write_commit(&repo, vec![old], "new");
2684
2685 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2686 vec![
2687 PushCommand {
2688 src: None,
2689 dst: "refs/heads/remove".into(),
2690 expected_old: Some(old),
2691 force: false,
2692 },
2693 PushCommand {
2694 src: Some(new),
2695 dst: "refs/heads/keep".into(),
2696 expected_old: Some(old),
2697 force: false,
2698 },
2699 ],
2700 default_options(),
2701 );
2702
2703 assert_eq!(plan.pack_objects, vec![new]);
2704 }
2705
2706 #[test]
2707 fn push_action_plan_inferred_pack_roots_dedupe_first_seen_order() {
2708 let repo = temp_repo("action-plan-dedupe-roots");
2709 let first = write_commit(&repo, Vec::new(), "first");
2710 let second = write_commit(&repo, Vec::new(), "second");
2711
2712 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2713 vec![
2714 PushCommand {
2715 src: Some(second),
2716 dst: "refs/heads/second".into(),
2717 expected_old: None,
2718 force: false,
2719 },
2720 PushCommand {
2721 src: Some(first),
2722 dst: "refs/heads/first".into(),
2723 expected_old: None,
2724 force: false,
2725 },
2726 PushCommand {
2727 src: Some(second),
2728 dst: "refs/tags/second".into(),
2729 expected_old: None,
2730 force: false,
2731 },
2732 PushCommand {
2733 src: Some(first),
2734 dst: "refs/tags/first".into(),
2735 expected_old: None,
2736 force: false,
2737 },
2738 ],
2739 default_options(),
2740 );
2741
2742 assert_eq!(plan.pack_objects, vec![second, first]);
2743 }
2744
2745 fn push_local_actions(
2746 local: &Path,
2747 remote: &Path,
2748 plan: &PushActionPlan,
2749 ) -> Result<PushOutcome> {
2750 let destination = PushDestination::Local {
2751 git_dir: remote.to_path_buf(),
2752 common_git_dir: remote.to_path_buf(),
2753 };
2754 let config = GitConfig::default();
2755 let mut credentials = NoCredentials;
2756 let mut progress = SilentProgress;
2757 push_actions(
2758 PushActionRequest {
2759 git_dir: local,
2760 common_git_dir: local,
2761 format: ObjectFormat::Sha1,
2762 config: &config,
2763 remote: "origin",
2764 destination: &destination,
2765 plan,
2766 },
2767 PushServices {
2768 credentials: &mut credentials,
2769 progress: &mut progress,
2770 },
2771 )
2772 }
2773
2774 #[test]
2775 fn local_push_returns_success_report_status_and_updates_ref() {
2776 let local = temp_repo("local-success");
2777 let remote = temp_repo("remote-success");
2778 let base = write_commit(&local, Vec::new(), "base");
2779 let tip = write_commit(&local, vec![base], "tip");
2780 set_ref(&local, "refs/heads/main", RefTarget::Direct(tip));
2781 set_ref(
2782 &local,
2783 "HEAD",
2784 RefTarget::Symbolic("refs/heads/main".into()),
2785 );
2786 let destination = PushDestination::Local {
2787 git_dir: remote.clone(),
2788 common_git_dir: remote.clone(),
2789 };
2790 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
2791 let options = default_options();
2792 let request = PushRequest {
2793 git_dir: &local,
2794 common_git_dir: &local,
2795 format: ObjectFormat::Sha1,
2796 config: &GitConfig::default(),
2797 remote: "origin",
2798 destination: &destination,
2799 refspecs: &refspecs,
2800 options: &options,
2801 };
2802 let mut credentials = NoCredentials;
2803 let mut progress = SilentProgress;
2804
2805 let outcome = push(
2806 request,
2807 PushServices {
2808 credentials: &mut credentials,
2809 progress: &mut progress,
2810 },
2811 )
2812 .expect("push should succeed");
2813
2814 assert_eq!(outcome.commands.len(), 1);
2815 let report = outcome.report.expect("local receive-pack reports status");
2816 assert!(matches!(report.unpack, ReceivePackUnpackStatus::Ok));
2817 assert!(matches!(
2818 report.commands.as_slice(),
2819 [ReceivePackCommandStatus::Ok { name }] if name == "refs/heads/main"
2820 ));
2821 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2822 assert_eq!(
2823 remote_refs
2824 .read_ref("refs/heads/main")
2825 .expect("remote ref should read"),
2826 Some(RefTarget::Direct(tip))
2827 );
2828 }
2829
2830 #[test]
2831 fn local_push_actions_preserves_exact_old_new_update() {
2832 let local = temp_repo("actions-update-local");
2833 let remote = temp_repo("actions-update-remote");
2834 let base = write_commit(&local, Vec::new(), "base");
2835 let remote_base = write_commit(&remote, Vec::new(), "base");
2836 assert_eq!(remote_base, base);
2837 let tip = write_commit(&local, vec![base], "tip");
2838 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2839 let plan = PushActionPlan::from_actions(
2840 vec![PushAction::Update {
2841 dst: "refs/heads/main".into(),
2842 old: base,
2843 new: tip,
2844 }],
2845 default_options(),
2846 );
2847
2848 let outcome = push_local_actions(&local, &remote, &plan).expect("push actions");
2849
2850 assert_eq!(outcome.commands.len(), 1);
2851 assert_eq!(outcome.commands[0].old_id, base);
2852 assert_eq!(outcome.commands[0].new_id, tip);
2853 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2854 assert_eq!(
2855 remote_refs
2856 .read_ref("refs/heads/main")
2857 .expect("remote ref should read"),
2858 Some(RefTarget::Direct(tip))
2859 );
2860 }
2861
2862 #[test]
2863 fn local_push_actions_honors_per_command_force() {
2864 let local = temp_repo("actions-command-force-local");
2865 let remote = temp_repo("actions-command-force-remote");
2866 let base = write_commit(&local, Vec::new(), "base");
2867 let remote_base = write_commit(&remote, Vec::new(), "base");
2868 assert_eq!(remote_base, base);
2869 let unrelated = write_commit(&local, Vec::new(), "unrelated");
2870 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2871
2872 let unforced = PushActionPlan::from_commands(
2873 vec![PushCommand {
2874 src: Some(unrelated),
2875 dst: "refs/heads/main".into(),
2876 expected_old: Some(base),
2877 force: false,
2878 }],
2879 default_options(),
2880 );
2881 let err = push_local_actions(&local, &remote, &unforced)
2882 .expect_err("non-fast-forward should reject without command force");
2883 assert!(err.to_string().contains("non-fast-forward"));
2884
2885 let forced = PushActionPlan::from_commands(
2886 vec![PushCommand {
2887 src: Some(unrelated),
2888 dst: "refs/heads/main".into(),
2889 expected_old: Some(base),
2890 force: true,
2891 }],
2892 default_options(),
2893 );
2894 let outcome = push_local_actions(&local, &remote, &forced).expect("command force pushes");
2895
2896 assert_eq!(outcome.commands.len(), 1);
2897 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2898 assert_eq!(
2899 remote_refs
2900 .read_ref("refs/heads/main")
2901 .expect("remote ref should read"),
2902 Some(RefTarget::Direct(unrelated))
2903 );
2904 }
2905
2906 #[test]
2907 fn local_push_actions_command_force_is_precise_for_non_ff_validation() {
2908 let local = temp_repo("actions-command-force-precise-local");
2909 let remote = temp_repo("actions-command-force-precise-remote");
2910 let base = write_commit(&local, Vec::new(), "base");
2911 let remote_base = write_commit(&remote, Vec::new(), "base");
2912 assert_eq!(remote_base, base);
2913 let forced_unrelated = write_commit(&local, Vec::new(), "forced unrelated");
2914 let unforced_unrelated = write_commit(&local, Vec::new(), "unforced unrelated");
2915 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
2916 set_ref(&remote, "refs/heads/topic", RefTarget::Direct(base));
2917 let plan = PushActionPlan::from_commands_and_infer_pack_roots(
2918 vec![
2919 PushCommand {
2920 src: Some(forced_unrelated),
2921 dst: "refs/heads/main".into(),
2922 expected_old: Some(base),
2923 force: true,
2924 },
2925 PushCommand {
2926 src: Some(unforced_unrelated),
2927 dst: "refs/heads/topic".into(),
2928 expected_old: Some(base),
2929 force: false,
2930 },
2931 ],
2932 default_options(),
2933 );
2934
2935 let err = push_local_actions(&local, &remote, &plan)
2936 .expect_err("only the forced command should bypass non-fast-forward validation");
2937
2938 assert!(err.to_string().contains("non-fast-forward update to topic"));
2939 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2940 assert_eq!(
2941 remote_refs
2942 .read_ref("refs/heads/main")
2943 .expect("remote ref should read"),
2944 Some(RefTarget::Direct(base))
2945 );
2946 assert_eq!(
2947 remote_refs
2948 .read_ref("refs/heads/topic")
2949 .expect("remote ref should read"),
2950 Some(RefTarget::Direct(base))
2951 );
2952 }
2953
2954 #[test]
2955 fn local_push_actions_stale_update_old_rejects_without_mutating() {
2956 let local = temp_repo("actions-stale-local");
2957 let remote = temp_repo("actions-stale-remote");
2958 let base = write_commit(&local, Vec::new(), "base");
2959 let remote_base = write_commit(&remote, Vec::new(), "base");
2960 assert_eq!(remote_base, base);
2961 let tip = write_commit(&local, vec![base], "tip");
2962 let concurrent = write_commit(&remote, vec![base], "concurrent");
2963 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2964 let plan = PushActionPlan::from_actions(
2965 vec![PushAction::Update {
2966 dst: "refs/heads/main".into(),
2967 old: base,
2968 new: tip,
2969 }],
2970 default_options(),
2971 );
2972
2973 let err = push_local_actions(&local, &remote, &plan).expect_err("stale old rejects");
2974
2975 assert!(err.to_string().contains("expected ref refs/heads/main"));
2976 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
2977 assert_eq!(
2978 remote_refs
2979 .read_ref("refs/heads/main")
2980 .expect("remote ref should read"),
2981 Some(RefTarget::Direct(concurrent))
2982 );
2983 }
2984
2985 #[test]
2986 fn local_push_actions_stale_delete_old_rejects_without_mutating() {
2987 let local = temp_repo("actions-delete-local");
2988 let remote = temp_repo("actions-delete-remote");
2989 let base = write_commit(&local, Vec::new(), "base");
2990 let remote_base = write_commit(&remote, Vec::new(), "base");
2991 assert_eq!(remote_base, base);
2992 let concurrent = write_commit(&remote, vec![base], "concurrent");
2993 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
2994 let plan = PushActionPlan::from_actions(
2995 vec![PushAction::Delete {
2996 dst: "refs/heads/main".into(),
2997 old: Some(base),
2998 }],
2999 default_options(),
3000 );
3001
3002 let err = push_local_actions(&local, &remote, &plan).expect_err("stale delete rejects");
3003
3004 assert!(err.to_string().contains("expected ref refs/heads/main"));
3005 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3006 assert_eq!(
3007 remote_refs
3008 .read_ref("refs/heads/main")
3009 .expect("remote ref should read"),
3010 Some(RefTarget::Direct(concurrent))
3011 );
3012 }
3013
3014 #[test]
3015 fn local_push_actions_create_rejects_existing_ref() {
3016 let local = temp_repo("actions-create-local");
3017 let remote = temp_repo("actions-create-remote");
3018 let base = write_commit(&local, Vec::new(), "base");
3019 let remote_base = write_commit(&remote, Vec::new(), "base");
3020 assert_eq!(remote_base, base);
3021 let tip = write_commit(&local, vec![base], "tip");
3022 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
3023 let plan = PushActionPlan::from_actions(
3024 vec![PushAction::Create {
3025 dst: "refs/heads/main".into(),
3026 new: tip,
3027 }],
3028 default_options(),
3029 );
3030
3031 let err = push_local_actions(&local, &remote, &plan).expect_err("create must be absent");
3032
3033 assert!(
3034 err.to_string()
3035 .contains("expected ref refs/heads/main to not already exist")
3036 );
3037 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3038 assert_eq!(
3039 remote_refs
3040 .read_ref("refs/heads/main")
3041 .expect("remote ref should read"),
3042 Some(RefTarget::Direct(base))
3043 );
3044 }
3045
3046 #[test]
3047 fn report_status_rejection_is_an_error() {
3048 let report = ReceivePackReportStatus {
3049 unpack: ReceivePackUnpackStatus::Ok,
3050 commands: vec![ReceivePackCommandStatus::Ng {
3051 name: "refs/heads/main".into(),
3052 message: "hook declined".into(),
3053 }],
3054 };
3055
3056 let err = validate_receive_pack_report(&report).expect_err("ng report should fail");
3057
3058 assert!(err.to_string().contains("hook declined"));
3059 }
3060
3061 #[test]
3062 fn failed_local_push_does_not_partially_mutate_remote_ref() {
3063 let local = temp_repo("local-rejected");
3064 let remote = temp_repo("remote-rejected");
3065 let base = write_commit(&local, Vec::new(), "base");
3066 let planned = write_commit(&local, vec![base], "planned");
3067 let concurrent = write_commit(&local, vec![base], "concurrent");
3068 set_ref(&local, "refs/heads/main", RefTarget::Direct(planned));
3069 set_ref(
3070 &local,
3071 "HEAD",
3072 RefTarget::Symbolic("refs/heads/main".into()),
3073 );
3074 set_ref(&remote, "refs/heads/main", RefTarget::Direct(base));
3075 let destination = PushDestination::Local {
3076 git_dir: remote.clone(),
3077 common_git_dir: remote.clone(),
3078 };
3079 let refspecs = vec!["refs/heads/main:refs/heads/main".to_string()];
3080 let options = default_options();
3081 let request = PushRequest {
3082 git_dir: &local,
3083 common_git_dir: &local,
3084 format: ObjectFormat::Sha1,
3085 config: &GitConfig::default(),
3086 remote: "origin",
3087 destination: &destination,
3088 refspecs: &refspecs,
3089 options: &options,
3090 };
3091 let mut credentials = NoCredentials;
3092 let mut progress = SilentProgress;
3093 let mut services = PushServices {
3094 credentials: &mut credentials,
3095 progress: &mut progress,
3096 };
3097 let plan = plan_push(request, &mut services).expect("push should plan");
3098
3099 set_ref(&remote, "refs/heads/main", RefTarget::Direct(concurrent));
3100 let _err = execute_push_plan(request, &mut services, plan)
3101 .expect_err("stale old id should reject the ref update");
3102
3103 let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
3104 assert_eq!(
3105 remote_refs
3106 .read_ref("refs/heads/main")
3107 .expect("remote ref should read"),
3108 Some(RefTarget::Direct(concurrent))
3109 );
3110 }
3111}