1use std::fs;
8use std::io::{BufRead, BufReader, Read, Write};
9use std::path::{Path, PathBuf};
10use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
11
12use sley_config::GitConfig;
13use sley_config::remotes::{remote_config_values, remote_exists, rewrite_url_with_config};
14use sley_core::{CliExit, GitError, ObjectFormat, ObjectId, Result};
15use sley_protocol::{RefAdvertisement, parse_refspec, refspec_map_source};
16use sley_refs::{FileRefStore, RefTarget, RefUpdate};
17
18use crate::{CredentialProvider, FetchOptions, FetchServices, ProgressSink};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct RemoteHelperSpec {
23 pub name: String,
25 pub alias: String,
27 pub url: Option<String>,
30}
31
32pub fn resolve_remote_helper(config: &GitConfig, remote: &str) -> Option<RemoteHelperSpec> {
38 let named = remote_exists(config, remote);
39 if named && let Some(vcs) = config.get("remote", Some(remote), "vcs") {
40 if native_helper_name(vcs) {
41 return None;
42 }
43 let url = remote_config_values(config, remote, "url")
44 .into_iter()
45 .next()
46 .map(|url| rewrite_url_with_config(config, &url, false));
47 return Some(RemoteHelperSpec {
48 name: vcs.to_string(),
49 alias: remote.to_string(),
50 url,
51 });
52 }
53
54 let resolved = if named {
55 remote_config_values(config, remote, "url")
56 .into_iter()
57 .next()
58 .map(|url| rewrite_url_with_config(config, &url, false))?
59 } else {
60 rewrite_url_with_config(config, remote, false)
61 };
62 if let Some((name, url)) = split_double_colon_helper(&resolved) {
63 if native_helper_name(name) {
64 return None;
65 }
66 return Some(RemoteHelperSpec {
67 name: name.to_string(),
68 alias: if named {
69 remote.to_string()
70 } else {
71 resolved.clone()
72 },
73 url: Some(url.to_string()),
74 });
75 }
76 let name = unknown_url_scheme(&resolved)?;
77 if native_helper_name(name) {
78 return None;
79 }
80 Some(RemoteHelperSpec {
81 name: name.to_string(),
82 alias: if named {
83 remote.to_string()
84 } else {
85 resolved.clone()
86 },
87 url: Some(resolved),
88 })
89}
90
91fn native_helper_name(name: &str) -> bool {
92 matches!(
93 name.to_ascii_lowercase().as_str(),
94 "file" | "local" | "ssh" | "git" | "ext" | "fd" | "http" | "https" | "ftp" | "ftps"
95 )
96}
97
98fn split_double_colon_helper(value: &str) -> Option<(&str, &str)> {
99 let (name, url) = value.split_once("::")?;
100 helper_scheme_name_is_valid(name).then_some((name, url))
101}
102
103fn unknown_url_scheme(value: &str) -> Option<&str> {
104 let (name, _) = value.split_once("://")?;
105 helper_scheme_name_is_valid(name).then_some(name)
106}
107
108fn helper_scheme_name_is_valid(name: &str) -> bool {
109 name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
110 && name
111 .bytes()
112 .skip(1)
113 .all(|byte| byte.is_ascii_alphanumeric() || b"+-.".contains(&byte))
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct RemoteHelperCapabilities {
119 pub import: bool,
120 pub export: bool,
121 pub option: bool,
122 pub object_format: bool,
123 pub signed_tags: bool,
124 pub no_private_update: bool,
125 pub refspecs: Vec<String>,
126 pub import_marks: Option<String>,
127 pub export_marks: Option<String>,
128}
129
130impl RemoteHelperCapabilities {
131 fn parse(lines: &[String]) -> Result<Self> {
132 let mut out = Self::default();
133 for line in lines {
134 let mandatory = line.starts_with('*');
135 let line = line.strip_prefix('*').unwrap_or(line);
136 let mut recognized = true;
137 match line {
138 "import" => out.import = true,
139 "export" => out.export = true,
140 "option" => out.option = true,
141 "object-format" => out.object_format = true,
142 "signed-tags" => out.signed_tags = true,
143 "no-private-update" => out.no_private_update = true,
144 _ => {
145 if let Some(value) = line.strip_prefix("refspec ") {
146 out.refspecs.push(value.to_string());
147 } else if let Some(value) = line.strip_prefix("import-marks ") {
148 out.import_marks = Some(value.to_string());
149 } else if let Some(value) = line.strip_prefix("export-marks ") {
150 out.export_marks = Some(value.to_string());
151 } else {
152 recognized = false;
153 }
154 }
155 }
156 if mandatory && !recognized {
157 return Err(GitError::Unsupported(format!(
158 "unknown mandatory remote-helper capability '{line}'"
159 )));
160 }
161 }
162 Ok(out)
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum RemoteHelperRefValue {
169 Object(ObjectId),
170 Unknown,
171 Symbolic(String),
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct RemoteHelperRef {
176 pub name: String,
177 pub value: RemoteHelperRefValue,
178}
179
180pub struct RemoteHelperExportRequest<'a> {
185 pub git_dir: &'a Path,
187 pub format: ObjectFormat,
189 pub signed_tags: bool,
191 pub import_marks: Option<&'a str>,
193 pub export_marks: Option<&'a str>,
195 pub refspecs: &'a [String],
197}
198
199pub trait RemoteHelperPlumbing {
205 fn fast_import(&mut self, git_dir: &Path, stream: &[u8]) -> Result<()>;
207 fn fast_export(&mut self, request: RemoteHelperExportRequest<'_>) -> Result<Vec<u8>>;
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum RemoteHelperEvent {
214 MissingRefspecCapability,
216 FastImportFailed,
218 PushRejected(String),
220}
221
222pub trait RemoteHelperEventSink {
224 fn event(&mut self, event: RemoteHelperEvent);
226}
227
228pub struct RemoteHelperFetchOperation<'a> {
230 pub git_dir: &'a Path,
232 pub format: ObjectFormat,
234 pub config: &'a GitConfig,
236 pub remote_name: &'a str,
238 pub spec: &'a RemoteHelperSpec,
240 pub refspecs: &'a [String],
242 pub options: &'a FetchOptions,
244}
245
246pub struct DiscoveredRemoteHelperFetchOperation<'a> {
249 pub git_dir: &'a Path,
251 pub format: ObjectFormat,
253 pub config: &'a GitConfig,
255 pub remote_name: &'a str,
257 pub refspecs: &'a [String],
259 pub options: &'a FetchOptions,
261}
262
263pub struct RemoteHelperFetchServices<'a> {
265 pub plumbing: &'a mut dyn RemoteHelperPlumbing,
267 pub events: &'a mut dyn RemoteHelperEventSink,
269 pub credentials: &'a mut dyn CredentialProvider,
271 pub progress: &'a mut dyn ProgressSink,
273 pub ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
275}
276
277#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
279pub struct RemoteHelperPushOptions {
280 pub force: bool,
282 pub dry_run: bool,
284}
285
286#[derive(Debug)]
288pub enum RemoteHelperPushError {
289 RefspecRequired,
292 MarksRequired,
295 Engine(GitError),
297}
298
299impl std::fmt::Display for RemoteHelperPushError {
300 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301 match self {
302 Self::RefspecRequired => {
303 formatter.write_str("remote-helper doesn't support push; refspec needed")
304 }
305 Self::MarksRequired => formatter.write_str("remote-helper export requires marks"),
306 Self::Engine(error) => error.fmt(formatter),
307 }
308 }
309}
310
311impl std::error::Error for RemoteHelperPushError {
312 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
313 match self {
314 Self::Engine(error) => Some(error),
315 Self::RefspecRequired | Self::MarksRequired => None,
316 }
317 }
318}
319
320impl From<GitError> for RemoteHelperPushError {
321 fn from(error: GitError) -> Self {
322 Self::Engine(error)
323 }
324}
325
326pub struct RemoteHelperPushOperation<'a> {
328 pub git_dir: &'a Path,
330 pub format: ObjectFormat,
332 pub config: &'a GitConfig,
334 pub remote_name: &'a str,
336 pub spec: &'a RemoteHelperSpec,
338 pub refspecs: &'a [String],
340 pub options: RemoteHelperPushOptions,
342}
343
344pub struct RemoteHelperPushServices<'a> {
346 pub plumbing: &'a mut dyn RemoteHelperPlumbing,
348 pub events: &'a mut dyn RemoteHelperEventSink,
350}
351
352#[derive(Debug, Clone, Default, PartialEq, Eq)]
354pub struct RemoteHelperPushOutcome {
355 pub expanded_refspecs: Vec<String>,
357 pub successful_refs: Vec<String>,
359 pub dry_run: bool,
361}
362
363pub struct RemoteHelperSession {
366 spec: RemoteHelperSpec,
367 format: ObjectFormat,
368 adopt_advertised_format: bool,
369 child: Child,
370 stdin: Option<ChildStdin>,
371 stdout: BufReader<ChildStdout>,
372 capabilities: RemoteHelperCapabilities,
373}
374
375impl RemoteHelperSession {
376 pub fn start(spec: RemoteHelperSpec, git_dir: &Path, format: ObjectFormat) -> Result<Self> {
377 let executable = format!("git-remote-{}", spec.name);
378 Self::start_with_executable_mode(spec, git_dir, format, false, executable)
379 }
380
381 pub fn start_for_discovery(
384 spec: RemoteHelperSpec,
385 git_dir: &Path,
386 provisional_format: ObjectFormat,
387 ) -> Result<Self> {
388 let executable = format!("git-remote-{}", spec.name);
389 Self::start_with_executable_mode(spec, git_dir, provisional_format, true, executable)
390 }
391
392 #[cfg(test)]
393 fn start_with_executable(
394 spec: RemoteHelperSpec,
395 git_dir: &Path,
396 format: ObjectFormat,
397 executable: impl AsRef<std::ffi::OsStr>,
398 ) -> Result<Self> {
399 Self::start_with_executable_mode(spec, git_dir, format, false, executable)
400 }
401
402 fn start_with_executable_mode(
403 spec: RemoteHelperSpec,
404 git_dir: &Path,
405 format: ObjectFormat,
406 adopt_advertised_format: bool,
407 executable: impl AsRef<std::ffi::OsStr>,
408 ) -> Result<Self> {
409 let mut command = Command::new(executable.as_ref());
410 command.arg(&spec.alias);
411 if let Some(url) = spec.url.as_deref() {
412 command.arg(url);
413 }
414 let mut child = command
415 .env("GIT_DIR", git_dir)
416 .stdin(Stdio::piped())
417 .stdout(Stdio::piped())
418 .stderr(Stdio::inherit())
419 .spawn()
420 .map_err(|err| {
421 GitError::Command(format!(
422 "unable to find remote helper for '{}': {err}",
423 spec.name
424 ))
425 })?;
426 let stdin = child.stdin.take().ok_or_else(|| {
427 GitError::Command(format!("remote helper '{}' has no stdin", spec.name))
428 })?;
429 let stdout = child.stdout.take().ok_or_else(|| {
430 GitError::Command(format!("remote helper '{}' has no stdout", spec.name))
431 })?;
432 let mut session = Self {
433 spec,
434 format,
435 adopt_advertised_format,
436 child,
437 stdin: Some(stdin),
438 stdout: BufReader::new(stdout),
439 capabilities: RemoteHelperCapabilities::default(),
440 };
441 session.write_line("capabilities")?;
442 let capability_lines = session.read_block()?;
443 if capability_lines.is_empty() {
444 return Err(session.aborted_error());
445 }
446 session.capabilities = RemoteHelperCapabilities::parse(&capability_lines)?;
447 if session.capabilities.option && session.capabilities.object_format {
448 session.write_line("option object-format true")?;
449 let response = session.read_line()?;
450 if response != "ok" && response != "unsupported" {
451 return Err(GitError::Command(format!(
452 "remote helper '{}' rejected object format {}: {response}",
453 session.spec.name,
454 format.name()
455 )));
456 }
457 }
458 Ok(session)
459 }
460
461 pub fn capabilities(&self) -> &RemoteHelperCapabilities {
462 &self.capabilities
463 }
464
465 pub fn object_format(&self) -> ObjectFormat {
466 self.format
467 }
468
469 pub fn list(&mut self) -> Result<Vec<RemoteHelperRef>> {
470 self.write_line("list")?;
471 let lines = self.read_block()?;
472 if lines.is_empty() {
473 return Err(self.aborted_error());
474 }
475 for line in &lines {
476 let Some(value) = line.strip_prefix(":object-format ") else {
477 continue;
478 };
479 let advertised = parse_helper_object_format(value)?;
480 if self.adopt_advertised_format {
481 self.format = advertised;
482 self.adopt_advertised_format = false;
483 } else if advertised != self.format {
484 return Err(GitError::InvalidObjectId(format!(
485 "remote helper uses {value}, local repository uses {}",
486 self.format.name()
487 )));
488 }
489 }
490 let mut refs = Vec::new();
491 for line in lines {
492 if line.starts_with(":object-format ") {
493 continue;
494 }
495 refs.push(parse_list_line(&line, self.capabilities.object_format)?);
496 }
497 Ok(refs)
498 }
499
500 pub fn set_option(&mut self, name: &str, value: &str) -> Result<bool> {
503 if !self.capabilities.option {
504 return Ok(false);
505 }
506 self.write_line(&format!("option {name} {value}"))?;
507 match self.read_line()?.as_str() {
508 "ok" => Ok(true),
509 "unsupported" => Ok(false),
510 response => Err(GitError::Command(format!(
511 "remote helper '{}' rejected option {name}: {response}",
512 self.spec.name
513 ))),
514 }
515 }
516
517 pub fn import(mut self, refs: &[String]) -> Result<Vec<u8>> {
521 if !self.capabilities.import {
522 return Err(GitError::Unsupported(format!(
523 "remote helper '{}' does not support import",
524 self.spec.name
525 )));
526 }
527 for reference in refs {
528 self.write_line(&format!("import {reference}"))?;
529 }
530 self.write_raw(b"\n")?;
531 drop(self.stdin.take());
532 let mut stream = Vec::new();
533 self.stdout.read_to_end(&mut stream)?;
534 let status = self.child.wait()?;
535 if !status.success() {
536 return Err(GitError::Command(format!(
537 "error while running remote helper '{}' import",
538 self.spec.name
539 )));
540 }
541 Ok(stream)
542 }
543
544 pub fn export(mut self, stream: &[u8]) -> Result<Vec<String>> {
546 if !self.capabilities.export {
547 return Err(GitError::Unsupported(format!(
548 "remote helper '{}' does not support export",
549 self.spec.name
550 )));
551 }
552 self.write_line("export")?;
553 self.write_raw(stream)?;
554 drop(self.stdin.take());
555 let mut response = String::new();
556 self.stdout.read_to_string(&mut response)?;
557 let status = self.child.wait()?;
558 if !status.success() {
559 return Err(GitError::Command(format!(
560 "error while running remote helper '{}' export",
561 self.spec.name
562 )));
563 }
564 Ok(response
565 .lines()
566 .map(str::trim_end)
567 .filter(|line| !line.is_empty())
568 .map(str::to_string)
569 .collect())
570 }
571
572 fn write_line(&mut self, line: &str) -> Result<()> {
573 self.write_raw(format!("{line}\n").as_bytes())
574 }
575
576 fn write_raw(&mut self, bytes: &[u8]) -> Result<()> {
577 let Some(stdin) = self.stdin.as_mut() else {
578 return Err(GitError::Command(format!(
579 "remote helper '{}' aborted session",
580 self.spec.name
581 )));
582 };
583 stdin.write_all(bytes)?;
584 stdin.flush()?;
585 Ok(())
586 }
587
588 fn read_line(&mut self) -> Result<String> {
589 let mut line = String::new();
590 let count = self.stdout.read_line(&mut line)?;
591 if count == 0 {
592 return Err(self.aborted_error());
593 }
594 Ok(line.trim_end_matches(['\r', '\n']).to_string())
595 }
596
597 fn read_block(&mut self) -> Result<Vec<String>> {
598 let mut lines = Vec::new();
599 loop {
600 let line = self.read_line()?;
601 if line.is_empty() {
602 return Ok(lines);
603 }
604 lines.push(line);
605 }
606 }
607
608 fn aborted_error(&mut self) -> GitError {
609 let _ = self.child.try_wait();
610 GitError::cli_exit(
611 CliExit::UserError,
612 format!("remote helper '{}' aborted session", self.spec.name),
613 )
614 }
615}
616
617impl Drop for RemoteHelperSession {
618 fn drop(&mut self) {
619 drop(self.stdin.take());
623 match self.child.try_wait() {
624 Ok(Some(_)) => {}
625 Ok(None) | Err(_) => {
626 let _ = self.child.kill();
627 let _ = self.child.wait();
628 }
629 }
630 }
631}
632
633fn parse_helper_object_format(value: &str) -> Result<ObjectFormat> {
634 match value {
635 "sha1" => Ok(ObjectFormat::Sha1),
636 "sha256" => Ok(ObjectFormat::Sha256),
637 _ => Err(GitError::InvalidFormat(format!(
638 "remote helper uses unknown object format {value}"
639 ))),
640 }
641}
642
643pub struct RemoteHelperFetchDiscovery {
647 session: RemoteHelperSession,
648 capabilities: RemoteHelperCapabilities,
649 refs: Vec<RemoteHelperRef>,
650 git_dir: PathBuf,
651}
652
653impl RemoteHelperFetchDiscovery {
654 pub fn object_format(&self) -> ObjectFormat {
655 self.session.object_format()
656 }
657}
658
659pub fn discover_remote_helper_fetch(
660 spec: RemoteHelperSpec,
661 git_dir: &Path,
662 provisional_format: ObjectFormat,
663) -> Result<RemoteHelperFetchDiscovery> {
664 let mut session = RemoteHelperSession::start_for_discovery(spec, git_dir, provisional_format)?;
665 let capabilities = session.capabilities().clone();
666 let refs = session.list()?;
667 Ok(RemoteHelperFetchDiscovery {
668 session,
669 capabilities,
670 refs,
671 git_dir: git_dir.to_path_buf(),
672 })
673}
674
675pub fn fetch_via_remote_helper(
680 request: RemoteHelperFetchOperation<'_>,
681 services: RemoteHelperFetchServices<'_>,
682) -> Result<crate::FetchOutcome> {
683 let mut session =
684 RemoteHelperSession::start(request.spec.clone(), request.git_dir, request.format)?;
685 let capabilities = session.capabilities().clone();
686 let refs = session.list()?;
687 fetch_via_discovered_remote_helper(
688 RemoteHelperFetchDiscovery {
689 session,
690 capabilities,
691 refs,
692 git_dir: request.git_dir.to_path_buf(),
693 },
694 DiscoveredRemoteHelperFetchOperation {
695 git_dir: request.git_dir,
696 format: request.format,
697 config: request.config,
698 remote_name: request.remote_name,
699 refspecs: request.refspecs,
700 options: request.options,
701 },
702 services,
703 )
704}
705
706pub fn fetch_via_discovered_remote_helper(
710 discovery: RemoteHelperFetchDiscovery,
711 request: DiscoveredRemoteHelperFetchOperation<'_>,
712 services: RemoteHelperFetchServices<'_>,
713) -> Result<crate::FetchOutcome> {
714 if discovery.git_dir != request.git_dir {
715 return Err(GitError::InvalidPath(
716 "remote helper discovery and import must use the same git directory".into(),
717 ));
718 }
719 if discovery.object_format() != request.format {
720 return Err(GitError::InvalidFormat(format!(
721 "remote helper uses {}, local repository uses {}",
722 discovery.object_format().name(),
723 request.format.name()
724 )));
725 }
726 let RemoteHelperFetchDiscovery {
727 session,
728 capabilities,
729 refs,
730 git_dir: _,
731 } = discovery;
732 if capabilities.refspecs.is_empty() {
733 services
734 .events
735 .event(RemoteHelperEvent::MissingRefspecCapability);
736 }
737 let import_refs = refs
738 .iter()
739 .filter(|reference| !matches!(reference.value, RemoteHelperRefValue::Symbolic(_)))
740 .map(|reference| reference.name.clone())
741 .collect::<Vec<_>>();
742 let source_refs_before_import = if capabilities.refspecs.is_empty() {
743 Vec::new()
744 } else {
745 snapshot_helper_source_refs(request.git_dir, request.format, &import_refs)?
746 };
747 let stream = match session.import(&import_refs) {
748 Ok(stream) => stream,
749 Err(error) => {
750 services.events.event(RemoteHelperEvent::FastImportFailed);
751 return Err(error);
752 }
753 };
754 let stream = rewrite_remote_helper_import_stream(&stream, &capabilities.refspecs)?;
755 services.plumbing.fast_import(request.git_dir, &stream)?;
756 let (advertisements, head_symref) = imported_remote_helper_advertisements(
757 request.git_dir,
758 request.format,
759 &capabilities,
760 &refs,
761 )?;
762 restore_helper_import_source_refs(request.git_dir, request.format, &source_refs_before_import)?;
763 crate::finalize_remote_helper_fetch(
764 crate::RemoteHelperFetchRequest {
765 git_dir: request.git_dir,
766 format: request.format,
767 config: request.config,
768 remote_name: request.remote_name,
769 advertisements: &advertisements,
770 head_symref,
771 refspecs: request.refspecs,
772 options: request.options,
773 },
774 FetchServices {
775 credentials: services.credentials,
776 progress: services.progress,
777 ref_hook: services.ref_hook,
778 },
779 )
780}
781
782pub fn push_via_remote_helper(
786 request: RemoteHelperPushOperation<'_>,
787 services: RemoteHelperPushServices<'_>,
788) -> std::result::Result<RemoteHelperPushOutcome, RemoteHelperPushError> {
789 let mut session =
790 RemoteHelperSession::start(request.spec.clone(), request.git_dir, request.format)?;
791 let capabilities = session.capabilities().clone();
792 let _remote_refs = session.list()?;
793 if capabilities.refspecs.is_empty() {
794 return Err(RemoteHelperPushError::RefspecRequired);
795 }
796 if capabilities.import_marks.is_none() && capabilities.export_marks.is_none() {
799 return Err(RemoteHelperPushError::MarksRequired);
800 }
801 if request.options.dry_run {
802 return Ok(RemoteHelperPushOutcome {
803 dry_run: true,
804 ..RemoteHelperPushOutcome::default()
805 });
806 }
807 if request.options.force {
808 let _ = session.set_option("force", "true")?;
809 }
810 let refspecs = expand_helper_push_refspecs(request.git_dir, request.format, request.refspecs)?;
811 let marks_snapshot = snapshot_marks(capabilities.export_marks.as_deref())?;
812 let stream = services.plumbing.fast_export(RemoteHelperExportRequest {
813 git_dir: request.git_dir,
814 format: request.format,
815 signed_tags: capabilities.signed_tags,
816 import_marks: capabilities.import_marks.as_deref(),
817 export_marks: capabilities.export_marks.as_deref(),
818 refspecs: &refspecs,
819 })?;
820 let response = match session.export(&stream) {
821 Ok(response) => response,
822 Err(error) => {
823 restore_marks(marks_snapshot)?;
824 return Err(error.into());
825 }
826 };
827 let mut failed = false;
828 let mut successful = Vec::new();
829 for line in response {
830 if let Some(reference) = line.strip_prefix("ok ") {
831 successful.push(reference.to_string());
832 } else if let Some(rest) = line.strip_prefix("error ") {
833 failed = true;
834 services
835 .events
836 .event(RemoteHelperEvent::PushRejected(rest.to_string()));
837 }
838 }
839 if failed {
840 return Err(GitError::Exit(1).into());
841 }
842 update_helper_push_tracking_refs(
843 request.git_dir,
844 request.format,
845 request.config,
846 request.remote_name,
847 &capabilities,
848 &refspecs,
849 &successful,
850 )?;
851 Ok(RemoteHelperPushOutcome {
852 expanded_refspecs: refspecs,
853 successful_refs: successful,
854 dry_run: false,
855 })
856}
857
858fn expand_helper_push_refspecs(
859 git_dir: &Path,
860 format: ObjectFormat,
861 refspecs: &[String],
862) -> Result<Vec<String>> {
863 let store = FileRefStore::new(git_dir, format);
864 let refs = store.list_refs()?;
865 let mut expanded = Vec::new();
866 for raw in refspecs {
867 let force = raw.starts_with('+');
868 let normalized = crate::normalize_push_refspec(raw);
869 let body = normalized.strip_prefix('+').unwrap_or(&normalized);
870 let (src, dst) = body.split_once(':').unwrap_or((body, body));
871 let (Some((src_prefix, src_suffix)), Some((dst_prefix, dst_suffix))) =
872 (src.split_once('*'), dst.split_once('*'))
873 else {
874 expanded.push(normalized);
875 continue;
876 };
877 for reference in &refs {
878 let Some(stem) = reference
879 .name
880 .strip_prefix(src_prefix)
881 .and_then(|rest| rest.strip_suffix(src_suffix))
882 else {
883 continue;
884 };
885 expanded.push(format!(
886 "{}{}:{}{}{}",
887 if force { "+" } else { "" },
888 reference.name,
889 dst_prefix,
890 stem,
891 dst_suffix
892 ));
893 }
894 }
895 Ok(expanded)
896}
897
898struct MarksSnapshot {
899 path: PathBuf,
900 contents: Option<Vec<u8>>,
901}
902
903fn snapshot_marks(path: Option<&str>) -> Result<Option<MarksSnapshot>> {
904 let Some(path) = path else {
905 return Ok(None);
906 };
907 let path = PathBuf::from(path);
908 let contents = match fs::read(&path) {
909 Ok(contents) => Some(contents),
910 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
911 Err(error) => return Err(error.into()),
912 };
913 Ok(Some(MarksSnapshot { path, contents }))
914}
915
916fn restore_marks(snapshot: Option<MarksSnapshot>) -> Result<()> {
917 let Some(snapshot) = snapshot else {
918 return Ok(());
919 };
920 match snapshot.contents {
921 Some(contents) => fs::write(snapshot.path, contents)?,
922 None => match fs::remove_file(snapshot.path) {
923 Ok(()) => {}
924 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
925 Err(error) => return Err(error.into()),
926 },
927 }
928 Ok(())
929}
930
931fn update_helper_push_tracking_refs(
932 git_dir: &Path,
933 format: ObjectFormat,
934 config: &GitConfig,
935 remote: &str,
936 capabilities: &RemoteHelperCapabilities,
937 refspecs: &[String],
938 successful: &[String],
939) -> Result<()> {
940 let store = FileRefStore::new(git_dir, format);
941 let private_mappings = capabilities
942 .refspecs
943 .iter()
944 .map(|spec| parse_refspec(spec))
945 .collect::<Result<Vec<_>>>()?;
946 let tracking_mappings = config
947 .get_all("remote", Some(remote), "fetch")
948 .into_iter()
949 .flatten()
950 .map(parse_refspec)
951 .collect::<Result<Vec<_>>>()?;
952 for refspec in refspecs {
953 let normalized = crate::normalize_push_refspec(refspec);
954 let body = normalized.strip_prefix('+').unwrap_or(&normalized);
955 let (source, destination) = body.split_once(':').unwrap_or((body, body));
956 if !successful.iter().any(|name| name == destination) {
957 continue;
958 }
959 let oid = if source.is_empty() {
960 None
961 } else {
962 sley_refs::resolve_ref_peeled(&store, source)?
963 };
964 let mut destinations = Vec::new();
965 if !capabilities.no_private_update {
966 for mapping in &private_mappings {
967 if let Some(name) = refspec_map_source(mapping, destination)? {
968 destinations.push(name);
969 }
970 }
971 }
972 for mapping in &tracking_mappings {
973 if let Some(name) = refspec_map_source(mapping, destination)? {
974 destinations.push(name);
975 }
976 }
977 destinations.sort();
978 destinations.dedup();
979 for name in destinations {
980 match oid {
981 Some(oid) => {
982 let mut transaction = store.transaction();
983 transaction.update(RefUpdate {
984 name,
985 expected: None,
986 new: RefTarget::Direct(oid),
987 reflog: None,
988 });
989 transaction.commit()?;
990 }
991 None if store.read_ref(&name)?.is_some() => {
992 store.delete_ref(&name)?;
993 }
994 None => {}
995 }
996 }
997 }
998 Ok(())
999}
1000
1001fn snapshot_helper_source_refs(
1002 git_dir: &Path,
1003 format: ObjectFormat,
1004 refs: &[String],
1005) -> Result<Vec<(String, Option<RefTarget>)>> {
1006 let store = FileRefStore::new(git_dir, format);
1007 refs.iter()
1008 .map(|name| store.read_ref(name).map(|target| (name.clone(), target)))
1009 .collect()
1010}
1011
1012fn restore_helper_import_source_refs(
1013 git_dir: &Path,
1014 format: ObjectFormat,
1015 refs: &[(String, Option<RefTarget>)],
1016) -> Result<()> {
1017 let store = FileRefStore::new(git_dir, format);
1018 for (name, previous) in refs {
1019 match previous {
1020 Some(target) => {
1021 let mut transaction = store.transaction();
1022 transaction.update(RefUpdate {
1023 name: name.clone(),
1024 expected: None,
1025 new: target.clone(),
1026 reflog: None,
1027 });
1028 transaction.commit()?;
1029 }
1030 None if store.read_ref(name)?.is_some() => {
1031 store.delete_ref(name)?;
1032 }
1033 None => {}
1034 }
1035 }
1036 Ok(())
1037}
1038
1039fn parse_list_line(line: &str, object_format_capability: bool) -> Result<RemoteHelperRef> {
1040 if line.starts_with(':') {
1041 return Err(GitError::InvalidFormat(format!(
1042 "unexpected remote-helper attribute: {line}"
1043 )));
1044 }
1045 let (value, name) = line
1046 .split_once(' ')
1047 .ok_or_else(|| GitError::InvalidFormat(format!("malformed remote-helper ref: {line}")))?;
1048 let value = if value == "?" {
1049 RemoteHelperRefValue::Unknown
1050 } else if let Some(target) = value.strip_prefix('@') {
1051 RemoteHelperRefValue::Symbolic(target.to_string())
1052 } else {
1053 let format = if object_format_capability && value.len() == ObjectFormat::Sha256.hex_len() {
1054 ObjectFormat::Sha256
1055 } else {
1056 ObjectFormat::Sha1
1057 };
1058 RemoteHelperRefValue::Object(ObjectId::from_hex(format, value)?)
1059 };
1060 Ok(RemoteHelperRef {
1061 name: name.to_string(),
1062 value,
1063 })
1064}
1065
1066pub fn imported_remote_helper_advertisements(
1070 git_dir: &Path,
1071 format: ObjectFormat,
1072 capabilities: &RemoteHelperCapabilities,
1073 refs: &[RemoteHelperRef],
1074) -> Result<(Vec<RefAdvertisement>, Option<String>)> {
1075 let store = FileRefStore::new(git_dir, format);
1076 let mappings = capabilities
1077 .refspecs
1078 .iter()
1079 .map(|spec| parse_refspec(spec))
1080 .collect::<Result<Vec<_>>>()?;
1081 let mut advertisements = Vec::new();
1082 let mut head_symref = None;
1083 for reference in refs {
1084 if let RemoteHelperRefValue::Symbolic(target) = &reference.value {
1085 if reference.name == "HEAD" {
1086 head_symref = Some(target.clone());
1087 }
1088 continue;
1089 }
1090 let oid = match reference.value {
1091 RemoteHelperRefValue::Object(oid) => oid,
1092 RemoteHelperRefValue::Unknown => {
1093 let mut mapped = None;
1094 for mapping in mappings.iter().filter(|mapping| !mapping.negative) {
1095 if let Some(destination) = refspec_map_source(mapping, &reference.name)? {
1096 mapped = Some(destination);
1097 break;
1098 }
1099 }
1100 let local_name = mapped.as_deref().unwrap_or(&reference.name);
1101 if let Some(oid) = helper_ref_oid(&store, local_name)? {
1102 oid
1103 } else if mapped.is_some()
1104 && let Some(oid) = helper_ref_oid(&store, &reference.name)?
1105 {
1106 let mut transaction = store.transaction();
1111 transaction.update(RefUpdate {
1112 name: local_name.to_string(),
1113 expected: None,
1114 new: RefTarget::Direct(oid),
1115 reflog: None,
1116 });
1117 transaction.commit()?;
1118 oid
1119 } else {
1120 return Err(GitError::not_found(format!(
1121 "remote-helper imported ref {local_name}"
1122 )));
1123 }
1124 }
1125 RemoteHelperRefValue::Symbolic(_) => unreachable!(),
1126 };
1127 advertisements.push(RefAdvertisement {
1128 oid,
1129 name: reference.name.clone(),
1130 capabilities: Vec::new(),
1131 });
1132 }
1133 if let Some(target) = head_symref.as_deref()
1134 && let Some(target_ref) = advertisements
1135 .iter()
1136 .find(|reference| reference.name == target)
1137 {
1138 advertisements.push(RefAdvertisement {
1139 oid: target_ref.oid,
1140 name: "HEAD".to_string(),
1141 capabilities: Vec::new(),
1142 });
1143 }
1144 Ok((advertisements, head_symref))
1145}
1146
1147pub fn rewrite_remote_helper_import_stream(stream: &[u8], refspecs: &[String]) -> Result<Vec<u8>> {
1152 if refspecs.is_empty() {
1153 return Ok(stream.to_vec());
1154 }
1155 let mappings = refspecs
1156 .iter()
1157 .map(|spec| parse_refspec(spec))
1158 .collect::<Result<Vec<_>>>()?;
1159 let mut out = Vec::with_capacity(stream.len());
1160 let mut offset = 0;
1161 while offset < stream.len() {
1162 let relative_end = stream[offset..].iter().position(|byte| *byte == b'\n');
1163 let line_end = relative_end.map_or(stream.len(), |end| offset + end);
1164 let line = &stream[offset..line_end];
1165 let has_newline = line_end < stream.len();
1166 if let Some(name) = line
1167 .strip_prefix(b"commit ")
1168 .or_else(|| line.strip_prefix(b"reset "))
1169 {
1170 let name = std::str::from_utf8(name)
1171 .map_err(|_| GitError::InvalidFormat("non-utf8 remote-helper ref".into()))?;
1172 let mut mapped = None;
1173 for mapping in mappings.iter().filter(|mapping| !mapping.negative) {
1174 if let Some(destination) = refspec_map_source(mapping, name)? {
1175 mapped = Some(destination);
1176 break;
1177 }
1178 }
1179 let prefix = if line.starts_with(b"commit ") {
1180 b"commit ".as_slice()
1181 } else {
1182 b"reset ".as_slice()
1183 };
1184 out.extend_from_slice(prefix);
1185 out.extend_from_slice(mapped.as_deref().unwrap_or(name).as_bytes());
1186 } else {
1187 out.extend_from_slice(line);
1188 }
1189 if has_newline {
1190 out.push(b'\n');
1191 }
1192 offset = line_end + usize::from(has_newline);
1193 if let Some(count) = line
1194 .strip_prefix(b"data ")
1195 .and_then(|count| std::str::from_utf8(count).ok())
1196 .and_then(|count| count.parse::<usize>().ok())
1197 {
1198 let data_end = offset.checked_add(count).ok_or_else(|| {
1199 GitError::InvalidFormat("remote-helper data length overflow".into())
1200 })?;
1201 if data_end > stream.len() {
1202 return Err(GitError::InvalidFormat(
1203 "remote-helper data payload is truncated".into(),
1204 ));
1205 }
1206 out.extend_from_slice(&stream[offset..data_end]);
1207 offset = data_end;
1208 } else if let Some(delimiter) = line.strip_prefix(b"data <<") {
1209 if delimiter.is_empty() {
1210 return Err(GitError::InvalidFormat(
1211 "remote-helper data delimiter is empty".into(),
1212 ));
1213 }
1214 loop {
1215 if offset >= stream.len() {
1216 return Err(GitError::InvalidFormat(
1217 "remote-helper delimited data is truncated".into(),
1218 ));
1219 }
1220 let relative_end = stream[offset..]
1221 .iter()
1222 .position(|byte| *byte == b'\n')
1223 .ok_or_else(|| {
1224 GitError::InvalidFormat(
1225 "remote-helper delimited data has no terminator".into(),
1226 )
1227 })?;
1228 let payload_end = offset + relative_end;
1229 let payload_line = &stream[offset..payload_end];
1230 out.extend_from_slice(&stream[offset..=payload_end]);
1231 offset = payload_end + 1;
1232 if payload_line == delimiter {
1233 break;
1234 }
1235 }
1236 }
1237 }
1238 Ok(out)
1239}
1240
1241fn helper_ref_oid(store: &FileRefStore, name: &str) -> Result<Option<ObjectId>> {
1242 Ok(match store.read_ref(name)? {
1243 Some(RefTarget::Direct(oid)) => Some(oid),
1244 Some(RefTarget::Symbolic(target)) => sley_refs::resolve_ref_peeled(store, &target)?,
1245 None => None,
1246 })
1247}
1248
1249#[cfg(test)]
1250mod tests {
1251 use super::*;
1252 use sley_config::{ConfigEntry, ConfigSection};
1253 use std::time::{SystemTime, UNIX_EPOCH};
1254
1255 fn helper_test_dir(label: &str) -> PathBuf {
1256 let nonce = SystemTime::now()
1257 .duration_since(UNIX_EPOCH)
1258 .expect("clock")
1259 .as_nanos();
1260 std::env::temp_dir().join(format!(
1261 "sley-remote-helper-{label}-{}-{nonce}",
1262 std::process::id()
1263 ))
1264 }
1265
1266 #[test]
1267 fn resolves_double_colon_and_vcs_helpers_but_not_core_helpers() {
1268 let empty = GitConfig::default();
1269 assert_eq!(
1270 resolve_remote_helper(&empty, "testgit::/tmp/repo"),
1271 Some(RemoteHelperSpec {
1272 name: "testgit".into(),
1273 alias: "testgit::/tmp/repo".into(),
1274 url: Some("/tmp/repo".into()),
1275 })
1276 );
1277 assert!(resolve_remote_helper(&empty, "https://example.com/repo").is_none());
1278 assert!(resolve_remote_helper(&empty, "fd::3").is_none());
1279
1280 let config = GitConfig {
1281 sections: vec![ConfigSection::new(
1282 "remote",
1283 Some("origin".into()),
1284 vec![
1285 ConfigEntry::new("vcs", Some("testgit".into())),
1286 ConfigEntry::new("url", Some("/tmp/repo".into())),
1287 ],
1288 )],
1289 ..GitConfig::default()
1290 };
1291 assert_eq!(
1292 resolve_remote_helper(&config, "origin"),
1293 Some(RemoteHelperSpec {
1294 name: "testgit".into(),
1295 alias: "origin".into(),
1296 url: Some("/tmp/repo".into()),
1297 })
1298 );
1299 let core_config = GitConfig {
1300 sections: vec![ConfigSection::new(
1301 "remote",
1302 Some("origin".into()),
1303 vec![ConfigEntry::new("vcs", Some("fd".into()))],
1304 )],
1305 ..GitConfig::default()
1306 };
1307 assert!(resolve_remote_helper(&core_config, "origin").is_none());
1308 }
1309
1310 #[test]
1311 fn parses_capabilities_and_unknown_refs() {
1312 let capabilities = RemoteHelperCapabilities::parse(&[
1313 "import".into(),
1314 "export".into(),
1315 "refspec refs/heads/*:refs/private/*".into(),
1316 "*import-marks /tmp/marks".into(),
1317 ])
1318 .expect("capabilities");
1319 assert!(capabilities.import && capabilities.export);
1320 assert_eq!(capabilities.refspecs, ["refs/heads/*:refs/private/*"]);
1321 assert_eq!(capabilities.import_marks.as_deref(), Some("/tmp/marks"));
1322 assert_eq!(
1323 parse_list_line("? refs/heads/main", false).expect("ref"),
1324 RemoteHelperRef {
1325 name: "refs/heads/main".into(),
1326 value: RemoteHelperRefValue::Unknown,
1327 }
1328 );
1329 assert!(RemoteHelperCapabilities::parse(&["*future-protocol".into()]).is_err());
1330 assert_eq!(
1331 RemoteHelperCapabilities::parse(&["future-protocol".into()])
1332 .expect("optional unknown capability"),
1333 RemoteHelperCapabilities::default()
1334 );
1335 }
1336
1337 #[test]
1338 fn rewrites_import_refs_without_touching_counted_data() {
1339 let stream =
1340 b"commit refs/heads/main\ndata 20\nreset refs/heads/x\n\nreset refs/heads/topic\n";
1341 let rewritten = rewrite_remote_helper_import_stream(
1342 stream,
1343 &["refs/heads/*:refs/private/heads/*".into()],
1344 )
1345 .expect("rewrite");
1346 assert_eq!(
1347 rewritten,
1348 b"commit refs/private/heads/main\ndata 20\nreset refs/heads/x\n\nreset refs/private/heads/topic\n"
1349 );
1350 }
1351
1352 #[test]
1353 fn rewrites_import_refs_without_touching_delimited_data() {
1354 let stream = b"commit refs/heads/main\ndata <<END\ncommit refs/heads/payload\nreset refs/heads/payload\nEND\nreset refs/heads/topic\n";
1355 let rewritten = rewrite_remote_helper_import_stream(
1356 stream,
1357 &["refs/heads/*:refs/private/heads/*".into()],
1358 )
1359 .expect("rewrite");
1360 assert_eq!(
1361 rewritten,
1362 b"commit refs/private/heads/main\ndata <<END\ncommit refs/heads/payload\nreset refs/heads/payload\nEND\nreset refs/private/heads/topic\n"
1363 );
1364 }
1365
1366 #[test]
1367 fn expands_helper_push_wildcards_and_updates_tracking_refs() {
1368 let git_dir = helper_test_dir("push-refs");
1369 fs::create_dir_all(git_dir.join("refs/heads")).expect("refs");
1370 let oid = ObjectId::from_hex(
1371 ObjectFormat::Sha1,
1372 "1111111111111111111111111111111111111111",
1373 )
1374 .expect("oid");
1375 fs::write(
1376 git_dir.join("refs/heads/main"),
1377 format!("{}\n", oid.to_hex()),
1378 )
1379 .expect("main ref");
1380 fs::write(
1381 git_dir.join("refs/heads/topic"),
1382 format!("{}\n", oid.to_hex()),
1383 )
1384 .expect("topic ref");
1385
1386 let expanded = expand_helper_push_refspecs(
1387 &git_dir,
1388 ObjectFormat::Sha1,
1389 &["+refs/heads/*:refs/heads/*".into()],
1390 )
1391 .expect("expand");
1392 assert_eq!(
1393 expanded,
1394 [
1395 "+refs/heads/main:refs/heads/main",
1396 "+refs/heads/topic:refs/heads/topic"
1397 ]
1398 );
1399
1400 let config = GitConfig {
1401 sections: vec![ConfigSection::new(
1402 "remote",
1403 Some("origin".into()),
1404 vec![ConfigEntry::new(
1405 "fetch",
1406 Some("+refs/heads/*:refs/remotes/origin/*".into()),
1407 )],
1408 )],
1409 ..GitConfig::default()
1410 };
1411 let capabilities = RemoteHelperCapabilities {
1412 refspecs: vec!["refs/heads/*:refs/private/*".into()],
1413 ..RemoteHelperCapabilities::default()
1414 };
1415 update_helper_push_tracking_refs(
1416 &git_dir,
1417 ObjectFormat::Sha1,
1418 &config,
1419 "origin",
1420 &capabilities,
1421 &["refs/heads/main:refs/heads/main".into()],
1422 &["refs/heads/main".into()],
1423 )
1424 .expect("tracking update");
1425 let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
1426 assert_eq!(
1427 store.read_ref("refs/private/main").expect("private ref"),
1428 Some(RefTarget::Direct(oid))
1429 );
1430 assert_eq!(
1431 store
1432 .read_ref("refs/remotes/origin/main")
1433 .expect("tracking ref"),
1434 Some(RefTarget::Direct(oid))
1435 );
1436 let _ = fs::remove_dir_all(git_dir);
1437 }
1438
1439 #[test]
1440 fn restores_marks_and_import_source_refs() {
1441 let git_dir = helper_test_dir("rollback");
1442 fs::create_dir_all(git_dir.join("refs/heads")).expect("refs");
1443 let marks = git_dir.join("marks");
1444 fs::write(&marks, b"old marks\n").expect("marks");
1445 let marks_path = marks.to_string_lossy().into_owned();
1446 let marks_snapshot = snapshot_marks(Some(&marks_path)).expect("marks snapshot");
1447 fs::write(&marks, b"new marks\n").expect("mutated marks");
1448 restore_marks(marks_snapshot).expect("restore marks");
1449 assert_eq!(fs::read(&marks).expect("marks read"), b"old marks\n");
1450
1451 let original = ObjectId::from_hex(
1452 ObjectFormat::Sha1,
1453 "2222222222222222222222222222222222222222",
1454 )
1455 .expect("original");
1456 let imported = ObjectId::from_hex(
1457 ObjectFormat::Sha1,
1458 "3333333333333333333333333333333333333333",
1459 )
1460 .expect("imported");
1461 fs::write(
1462 git_dir.join("refs/heads/main"),
1463 format!("{}\n", original.to_hex()),
1464 )
1465 .expect("original ref");
1466 let names = vec!["refs/heads/main".into(), "refs/heads/new".into()];
1467 let refs = snapshot_helper_source_refs(&git_dir, ObjectFormat::Sha1, &names)
1468 .expect("ref snapshot");
1469 fs::write(
1470 git_dir.join("refs/heads/main"),
1471 format!("{}\n", imported.to_hex()),
1472 )
1473 .expect("mutated ref");
1474 fs::write(
1475 git_dir.join("refs/heads/new"),
1476 format!("{}\n", imported.to_hex()),
1477 )
1478 .expect("new ref");
1479 restore_helper_import_source_refs(&git_dir, ObjectFormat::Sha1, &refs)
1480 .expect("restore refs");
1481 let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
1482 assert_eq!(
1483 store.read_ref("refs/heads/main").expect("main"),
1484 Some(RefTarget::Direct(original))
1485 );
1486 assert_eq!(store.read_ref("refs/heads/new").expect("new"), None);
1487 let _ = fs::remove_dir_all(git_dir);
1488 }
1489
1490 #[test]
1491 fn classifies_push_setup_errors_without_message_matching() {
1492 assert_eq!(
1493 RemoteHelperPushError::RefspecRequired.to_string(),
1494 "remote-helper doesn't support push; refspec needed"
1495 );
1496 assert_eq!(
1497 RemoteHelperPushError::MarksRequired.to_string(),
1498 "remote-helper export requires marks"
1499 );
1500 assert!(matches!(
1501 RemoteHelperPushError::from(GitError::Exit(7)),
1502 RemoteHelperPushError::Engine(GitError::Exit(7))
1503 ));
1504 }
1505
1506 #[cfg(unix)]
1507 #[test]
1508 fn rejected_mandatory_capability_reaps_a_waiting_helper() {
1509 use std::os::unix::fs::PermissionsExt;
1510 use std::time::{Duration, Instant};
1511
1512 let nonce = SystemTime::now()
1513 .duration_since(UNIX_EPOCH)
1514 .expect("clock")
1515 .as_nanos();
1516 let root = std::env::temp_dir().join(format!(
1517 "sley-remote-helper-drop-{}-{nonce}",
1518 std::process::id()
1519 ));
1520 std::fs::create_dir_all(&root).expect("temp dir");
1521 let helper = root.join("git-remote-waiting");
1522 std::fs::write(
1523 &helper,
1524 b"#!/bin/sh\nread command\nprintf '*future-protocol\\n\\n'\nsleep 30\n",
1525 )
1526 .expect("helper script");
1527 let mut permissions = std::fs::metadata(&helper).expect("metadata").permissions();
1528 permissions.set_mode(0o755);
1529 std::fs::set_permissions(&helper, permissions).expect("permissions");
1530
1531 let started = Instant::now();
1532 let result = RemoteHelperSession::start_with_executable(
1533 RemoteHelperSpec {
1534 name: "waiting".into(),
1535 alias: "origin".into(),
1536 url: Some("unused".into()),
1537 },
1538 &root,
1539 ObjectFormat::Sha1,
1540 &helper,
1541 );
1542 assert!(result.is_err());
1543 assert!(started.elapsed() < Duration::from_secs(2));
1544 let _ = std::fs::remove_dir_all(root);
1545 }
1546
1547 #[cfg(unix)]
1548 #[test]
1549 fn helper_abort_during_capabilities_is_a_typed_fatal_error() {
1550 use std::os::unix::fs::PermissionsExt;
1551
1552 let root = helper_test_dir("abort-capabilities");
1553 fs::create_dir_all(&root).expect("temp dir");
1554 let helper = root.join("git-remote-broken");
1555 fs::write(&helper, b"#!/bin/sh\nread command\nexit 1\n").expect("helper script");
1556 let mut permissions = fs::metadata(&helper).expect("metadata").permissions();
1557 permissions.set_mode(0o755);
1558 fs::set_permissions(&helper, permissions).expect("permissions");
1559
1560 let error = RemoteHelperSession::start_with_executable(
1561 RemoteHelperSpec {
1562 name: "broken".into(),
1563 alias: "broken://example.com/repo".into(),
1564 url: Some("broken://example.com/repo".into()),
1565 },
1566 &root,
1567 ObjectFormat::Sha1,
1568 &helper,
1569 )
1570 .err()
1571 .expect("helper should abort");
1572 assert_eq!(
1573 error,
1574 GitError::cli_exit(CliExit::UserError, "remote helper 'broken' aborted session")
1575 );
1576 let _ = fs::remove_dir_all(root);
1577 }
1578
1579 #[cfg(unix)]
1580 #[test]
1581 fn discovery_adopts_sha256_without_restarting_or_mutating_repository() {
1582 use std::os::unix::fs::PermissionsExt;
1583
1584 let git_dir = helper_test_dir("discover-sha256");
1585 fs::create_dir_all(git_dir.join("objects/pack")).expect("objects");
1586 fs::create_dir_all(git_dir.join("refs/heads")).expect("refs");
1587 fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("HEAD");
1588 let helper = git_dir.join("git-remote-discovery");
1589 fs::write(
1590 &helper,
1591 b"#!/bin/sh\nread command\ntest \"$command\" = capabilities || exit 2\nprintf 'import\\noption\\nobject-format\\n\\n'\nread command\ntest \"$command\" = 'option object-format true' || exit 3\nprintf 'ok\\n'\nread command\ntest \"$command\" = list || exit 4\nprintf ':object-format sha256\\n? refs/heads/main\\n@refs/heads/main HEAD\\n\\n'\nread command\ntest \"$command\" = 'import refs/heads/main' || exit 5\nread command\ntest -z \"$command\" || exit 6\nprintf 'done\\n'\n",
1592 )
1593 .expect("helper script");
1594 let mut permissions = fs::metadata(&helper).expect("metadata").permissions();
1595 permissions.set_mode(0o755);
1596 fs::set_permissions(&helper, permissions).expect("permissions");
1597
1598 let mut session = RemoteHelperSession::start_with_executable_mode(
1599 RemoteHelperSpec {
1600 name: "discovery".into(),
1601 alias: "origin".into(),
1602 url: Some("unused".into()),
1603 },
1604 &git_dir,
1605 ObjectFormat::Sha1,
1606 true,
1607 &helper,
1608 )
1609 .expect("start discovery");
1610 let refs = session.list().expect("list");
1611 assert_eq!(session.object_format(), ObjectFormat::Sha256);
1612 assert_eq!(refs.len(), 2);
1613 assert!(
1614 FileRefStore::new(&git_dir, ObjectFormat::Sha1)
1615 .list_refs()
1616 .expect("refs remain empty")
1617 .is_empty()
1618 );
1619 assert!(
1620 fs::read_dir(git_dir.join("objects/pack"))
1621 .expect("pack dir")
1622 .next()
1623 .is_none()
1624 );
1625 assert_eq!(
1626 session
1627 .import(&["refs/heads/main".into()])
1628 .expect("continue same session"),
1629 b"done\n"
1630 );
1631 let _ = fs::remove_dir_all(git_dir);
1632 }
1633}