Skip to main content

sley_remote/
helper.rs

1//! Git remote-helper discovery and line-protocol support.
2//!
3//! A remote helper is user-supplied (`git-remote-<name>` on `PATH`). Built-in
4//! transports are deliberately excluded here: Sley must never fall through to
5//! an installed Git's core `git-remote-http` (or similar) executable.
6
7use 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/// A resolved user-owned remote-helper invocation.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct RemoteHelperSpec {
23    /// The suffix in `git-remote-<name>`.
24    pub name: String,
25    /// The helper's first argument (a configured remote name or literal URL).
26    pub alias: String,
27    /// The optional helper URL argument. `remote.<name>.vcs` is valid without a
28    /// URL, so this is intentionally optional.
29    pub url: Option<String>,
30}
31
32/// Resolve a custom remote helper from a remote name/URL and effective config.
33///
34/// Returns `None` for Sley's native transports. In particular, names used by
35/// Git's core remote helpers are never returned, preventing an installed Git's
36/// executables from becoming an accidental implementation dependency.
37pub 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/// Capabilities advertised by a remote helper.
117#[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/// One entry from a helper's `list` response.
167#[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
180/// A runtime request for the fast-export half of a remote-helper push.
181///
182/// The helper engine owns protocol sequencing and supplies the resolved
183/// capability values. The caller owns how native fast-export is executed.
184pub struct RemoteHelperExportRequest<'a> {
185    /// Local repository receiving the helper operation.
186    pub git_dir: &'a Path,
187    /// Object format negotiated with the helper.
188    pub format: ObjectFormat,
189    /// Preserve signed tag objects verbatim during export.
190    pub signed_tags: bool,
191    /// Existing marks file the helper asked fast-export to consume.
192    pub import_marks: Option<&'a str>,
193    /// Marks file the helper asked fast-export to update.
194    pub export_marks: Option<&'a str>,
195    /// Expanded source-to-destination refspecs to export.
196    pub refspecs: &'a [String],
197}
198
199/// Injected native plumbing used by import/export remote helpers.
200///
201/// The CLI implementation re-enters the currently running Sley executable;
202/// embedders can provide in-process implementations without depending on an
203/// installed Git executable.
204pub trait RemoteHelperPlumbing {
205    /// Install one helper-produced fast-import stream into `git_dir`.
206    fn fast_import(&mut self, git_dir: &Path, stream: &[u8]) -> Result<()>;
207    /// Produce the fast-export stream requested by the helper engine.
208    fn fast_export(&mut self, request: RemoteHelperExportRequest<'_>) -> Result<Vec<u8>>;
209}
210
211/// Structured presentation events emitted while executing a helper operation.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum RemoteHelperEvent {
214    /// Import helper omitted the recommended private `refspec` capability.
215    MissingRefspecCapability,
216    /// The helper process failed while producing its fast-import stream.
217    FastImportFailed,
218    /// Helper rejected a destination and supplied this protocol detail.
219    PushRejected(String),
220}
221
222/// Presentation seam for helper warnings and per-ref rejection diagnostics.
223pub trait RemoteHelperEventSink {
224    /// Receive one structured event in protocol order.
225    fn event(&mut self, event: RemoteHelperEvent);
226}
227
228/// Fully resolved inputs for a remote-helper fetch.
229pub struct RemoteHelperFetchOperation<'a> {
230    /// Local repository `$GIT_DIR`.
231    pub git_dir: &'a Path,
232    /// Local repository object format.
233    pub format: ObjectFormat,
234    /// Effective repository configuration.
235    pub config: &'a GitConfig,
236    /// Remote name used for fetch config and FETCH_HEAD descriptions.
237    pub remote_name: &'a str,
238    /// Already-resolved user-owned helper invocation.
239    pub spec: &'a RemoteHelperSpec,
240    /// Caller-requested fetch refspecs.
241    pub refspecs: &'a [String],
242    /// Ordinary fetch behavior applied after helper import.
243    pub options: &'a FetchOptions,
244}
245
246/// Fetch inputs used after a live helper has already negotiated capabilities,
247/// refs, and object format during clone bootstrap.
248pub struct DiscoveredRemoteHelperFetchOperation<'a> {
249    /// Final repository `$GIT_DIR`; must equal the discovery directory.
250    pub git_dir: &'a Path,
251    /// Object format adopted from helper discovery.
252    pub format: ObjectFormat,
253    /// Effective configuration after clone bootstrap finalization.
254    pub config: &'a GitConfig,
255    /// Configured remote name used for fetch finalization.
256    pub remote_name: &'a str,
257    /// Caller-requested fetch refspecs.
258    pub refspecs: &'a [String],
259    /// Ordinary fetch behavior applied after helper import.
260    pub options: &'a FetchOptions,
261}
262
263/// Runtime seams used by a remote-helper fetch.
264pub struct RemoteHelperFetchServices<'a> {
265    /// Native fast-import/export runtime.
266    pub plumbing: &'a mut dyn RemoteHelperPlumbing,
267    /// Warning and rejection presentation sink.
268    pub events: &'a mut dyn RemoteHelperEventSink,
269    /// Credential provider passed to ordinary fetch finalization.
270    pub credentials: &'a mut dyn CredentialProvider,
271    /// Fetch progress and prune-event sink.
272    pub progress: &'a mut dyn ProgressSink,
273    /// Optional reference-transaction hook runner.
274    pub ref_hook: Option<&'a dyn sley_refs::ReferenceTransactionHook>,
275}
276
277/// Controls for the protocol-affecting portion of a remote-helper push.
278#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
279pub struct RemoteHelperPushOptions {
280    /// Ask an option-capable helper to accept non-fast-forward updates.
281    pub force: bool,
282    /// Validate and discover the helper without exporting objects or refs.
283    pub dry_run: bool,
284}
285
286/// Classified failure from [`push_via_remote_helper`].
287#[derive(Debug)]
288pub enum RemoteHelperPushError {
289    /// The helper omitted the mandatory private `refspec` mapping needed by the
290    /// import/export push protocol.
291    RefspecRequired,
292    /// Git v2.55 rejects export helpers that advertise neither import nor
293    /// export marks; callers preserve that oracle-visible classification.
294    MarksRequired,
295    /// Any other protocol, repository, runtime, or ref-update failure.
296    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
326/// Fully resolved inputs for a remote-helper push.
327pub struct RemoteHelperPushOperation<'a> {
328    /// Local repository `$GIT_DIR`.
329    pub git_dir: &'a Path,
330    /// Local repository object format.
331    pub format: ObjectFormat,
332    /// Effective repository configuration.
333    pub config: &'a GitConfig,
334    /// Remote name used to update configured tracking refs.
335    pub remote_name: &'a str,
336    /// Already-resolved user-owned helper invocation.
337    pub spec: &'a RemoteHelperSpec,
338    /// Caller-requested push refspecs before wildcard expansion.
339    pub refspecs: &'a [String],
340    /// Protocol-affecting push controls.
341    pub options: RemoteHelperPushOptions,
342}
343
344/// Runtime seams used by a remote-helper push.
345pub struct RemoteHelperPushServices<'a> {
346    /// Native fast-import/export runtime.
347    pub plumbing: &'a mut dyn RemoteHelperPlumbing,
348    /// Warning and rejection presentation sink.
349    pub events: &'a mut dyn RemoteHelperEventSink,
350}
351
352/// Structured result of a successful remote-helper push.
353#[derive(Debug, Clone, Default, PartialEq, Eq)]
354pub struct RemoteHelperPushOutcome {
355    /// Concrete refspecs sent to fast-export after wildcard expansion.
356    pub expanded_refspecs: Vec<String>,
357    /// Destination ref names acknowledged by the helper.
358    pub successful_refs: Vec<String>,
359    /// Whether execution stopped after helper discovery because of `dry_run`.
360    pub dry_run: bool,
361}
362
363/// A live remote-helper process. The caller may inspect capabilities/listing,
364/// then consume the session into either an import or export operation.
365pub 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    /// Start a helper against a valid provisional repository, allowing its
382    /// `list` response to select the final object format before import begins.
383    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    /// Negotiate one standard remote-helper option. Returns `false` when the
501    /// helper reports `unsupported`.
502    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    /// Request an import and return the complete fast-import byte stream.
518    /// The session is consumed: closing helper stdin after the request lets a
519    /// one-operation helper exit without leaving a protocol process behind.
520    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    /// Send a fast-export stream and return the helper's status response.
545    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        // Closing stdin first lets well-behaved helpers terminate naturally.
620        // Drop cannot wait unboundedly for a helper that ignores EOF, so reap an
621        // already-exited child and otherwise kill then wait (preventing zombies).
622        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
643/// A live helper after capability and ref discovery, before any import or
644/// local object/ref mutation. Clone can finalize its provisional repository's
645/// object format and then continue this same protocol process.
646pub 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
675/// Execute a user-owned remote helper's import flow and finalize it through the
676/// ordinary fetch engine. Repository semantics remain identical to native
677/// transports: refspec planning, FETCH_HEAD, pruning, and ref transactions all
678/// pass through [`crate::finalize_remote_helper_fetch`].
679pub 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
706/// Continue a previously discovered helper session after clone has finalized
707/// the provisional repository's object format. No second helper process is
708/// started, so helper-private marks and protocol state remain intact.
709pub 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
782/// Execute a user-owned remote helper's export flow, including wildcard
783/// expansion, marks rollback on helper failure, and tracking/private-ref
784/// updates for successful destinations.
785pub 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    // Git v2.55's import/export helper path still treats a marks-free export as
797    // unsupported. Keep that oracle behavior until the enrolled TODO changes.
798    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
1066/// Convert a helper listing into ordinary advertisements after its import
1067/// stream has been installed. Unknown object IDs are resolved from the private
1068/// namespaces declared by `refspec` capabilities.
1069pub 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                    // Some importers (including older Sley fast-export) leave a
1107                    // pattern refspec's source spelling in the stream. Preserve
1108                    // the helper contract by materializing its declared private
1109                    // namespace before normal fetch ref planning continues.
1110                    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
1147/// Rewrite branch/reset destinations in a helper-provided fast-import stream
1148/// through its declared import refspecs. This is byte-aware: counted `data N`
1149/// payloads are copied verbatim, so blob or message contents that resemble
1150/// fast-import commands are never interpreted as protocol lines.
1151pub 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}