girt 0.18.0

An incremental, idiomatic Rust implementation of Git
Documentation
use std::sync::atomic::AtomicBool;

use super::live_progress::LiveProgress;
use super::{PreparedPush, PushAdvertisement, PushError, PushFailure, PushReport, protocol};
use crate::packet::Wire;
use crate::transport::TransportControl;
use crate::transport::http::{HttpRemote, RequestBody};

/// Outcome of an HTTP push whose caller inspected the receive-pack advertisement.
#[derive(Debug)]
#[non_exhaustive]
pub enum HttpPushOutcome {
    /// The caller declined before the receive-pack POST. No update command was sent.
    Declined,
    /// The accepted operation completed with a report. Empty batches send no POST.
    /// Inspect each reference for rejection or partial success.
    Sent(PushReport),
}

/// Sends a prepared push through smart-HTTP discovery and one receive-pack RPC.
///
/// Uses [`super::send`]'s expectation, receiver-root, report-status and non-atomic update
/// contracts. Pack compression and exclusion are selected during [`PreparedPush`] construction.
/// Discovery checks required capabilities before any POST; exact old values travel in each
/// command for the receiver to enforce. Empty commands perform discovery only.
/// Requires a Tokio runtime; [`HttpRemote`] defines TLS, explicit authentication and network
/// controls.
///
/// Consumes the prepared push, moving command bytes and streaming its owned temporary pack into
/// the POST in bounded chunks. Prepare a new push for another attempt after inspecting uncertain
/// outcomes. Response retention is bounded by `max_status_bytes`; advertisement retention by
/// `max_advertisement_bytes`. Complete valid status packets received before a body interruption
/// remain report evidence. No automatic retries occur, including HTTP authentication challenges or
/// redirects.
///
/// # Errors
///
/// Discovery/preflight failures are [`PushError::NotSent`]. After starting the RPC, every failure
/// (including HTTP status, cancellation, truncation or invalid media type) is conservatively
/// [`PushError::Uncertain`]. Inspect remote refs before retrying. An HTTP failure alone never
/// proves rejection. Complete Git rejection reports return `Ok`; inspect each reference status.
pub async fn send_http(
    remote: &HttpRemote,
    prepared: PreparedPush,
    control: TransportControl<'_>,
) -> Result<PushReport, PushError> {
    match send_http_checked(remote, prepared, control, |_| true).await? {
        HttpPushOutcome::Sent(report) => Ok(report),
        HttpPushOutcome::Declined => unreachable!("unconditional HTTP push cannot decline"),
    }
}

/// Sends a prepared push only when the caller accepts the live receive-pack advertisement.
///
/// After bounded discovery and capability validation, `should_send` receives the validated
/// advertised tips. Returning `false` yields [`HttpPushOutcome::Declined`] without a POST or
/// receiver mutation; the caller can choose another transport. Returning `true` retains
/// [`send_http`]'s exact old-value commands and structured report. The callback runs synchronously
/// on the caller's async task and should return promptly. It cannot change the prepared commands.
/// Advertised values can change after the callback; the receiver still enforces each command's
/// expected old value. An absent advertised tip may also represent a hidden reference.
///
/// Preparation has already consumed local CPU and memory before this call. Discovery and declined
/// decisions do not install a pack. Once the POST begins, failures are
/// [`PushError::Uncertain`] and must not trigger an automatic retry or fallback.
///
/// # Errors
///
/// Discovery, capability, cancellation and request-encoding failures before the POST are
/// [`PushError::NotSent`]. Post-send failures retain the valid response prefix in
/// [`PushError::Uncertain`]. Complete receiver rejections are returned in
/// [`HttpPushOutcome::Sent`] and must be inspected per reference.
///
/// # Examples
///
/// Push only if the remote's `main` is still where the application last saw it. The check runs on
/// the live advertisement, before any update command is sent. This needs a server, so it is not
/// run:
///
/// ```no_run
/// use std::sync::atomic::AtomicBool;
///
/// use girt::push::{HttpPushOutcome, PreparedPush, PushCommand, PushLimits, send_http_checked};
/// use girt::refs::{RefName, Target};
/// use girt::transport::TransportControl;
/// use girt::transport::http::HttpRemote;
/// use girt::{ObjectId, PackLimits, Repository};
///
/// let repo = Repository::open("project")?;
/// let commit = |name: &str| -> Result<ObjectId, Box<dyn std::error::Error>> {
///     match repo.references()?.read(&RefName::new(name)?)? {
///         Some(Target::Direct(id)) => Ok(id),
///         _ => Err(format!("{name} does not point directly at a commit").into()),
///     }
/// };
///
/// // Where `main` was at the last fetch, and the local commit to publish.
/// let last_seen = commit("refs/remotes/origin/main")?;
/// let new = commit("refs/heads/main")?;
/// let main = RefName::new("refs/heads/main")?;
///
/// let objects = repo.objects_with(PackLimits::default())?;
/// let cancel = AtomicBool::new(false);
///
/// let command = PushCommand::new(main.clone(), Some(last_seen), new);
/// let prepared = PreparedPush::new(
///     &objects,
///     vec![command],
///     // A full transfer: a pack that relies on `last_seen` would be refused before the callback
///     // runs if `main` has moved, as `PushFailure::KnowledgeChanged`.
///     &[],
///     PushLimits::default(),
///     &cancel,
/// )?;
///
/// let runtime = tokio::runtime::Builder::new_current_thread()
///     .enable_all()
///     .build()?;
/// let outcome = runtime.block_on(async {
///     let remote = HttpRemote::new("https://example.com/project.git", &[], &[])?;
///     let outcome = send_http_checked(
///         &remote,
///         prepared,
///         TransportControl::new(&cancel),
///         |advertisement| advertisement.target(&main) == Some(last_seen),
///     )
///     .await?;
///     Ok::<_, Box<dyn std::error::Error>>(outcome)
/// })?;
///
/// match outcome {
///     HttpPushOutcome::Sent(report) => println!("all succeeded: {}", report.all_succeeded()),
///     // No update command was sent: fetch, reconcile, and try again.
///     HttpPushOutcome::Declined => println!("main is not where it was at the last fetch"),
///     _ => {}
/// }
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub async fn send_http_checked(
    remote: &HttpRemote,
    prepared: PreparedPush,
    control: TransportControl<'_>,
    should_send: impl FnOnce(&PushAdvertisement) -> bool,
) -> Result<HttpPushOutcome, PushError> {
    send_http_checked_with_progress(remote, prepared, control, should_send, |_| {}).await
}

/// Sends a checked HTTP push while delivering receiver progress before the response completes.
///
/// Call [`PreparedPush::with_progress`] before sending to request sideband progress. When the
/// receiver supports it, `progress` receives each complete channel-2 payload once, in wire order,
/// as borrowed bytes without UTF-8 conversion. No callback occurs when sideband is not negotiated.
/// These messages describe receiver work. [`super::PreparationProgress`] reports preparation
/// separately; upload has no progress callback.
///
/// The callback runs synchronously on the caller's async task and should return promptly. Its
/// messages are untrusted, advisory output: they do not establish acceptance or replace inspection
/// of the final report. Complete progress packets remain in [`PushReport::progress`], including
/// reports returned with uncertain failures. Cancellation still uses [`TransportControl`].
/// Malformed framing or a terminal sideband packet stops notification; the final parser determines
/// the structured result. Bytes beyond the response budget never reach the callback.
///
/// # Errors
///
/// Uses [`send_http_checked`]'s preflight, decline and uncertain-outcome contracts. A callback may
/// run before a later transport or protocol failure; inspect remote refs before retrying an
/// uncertain push.
pub async fn send_http_checked_with_progress(
    remote: &HttpRemote,
    prepared: PreparedPush,
    control: TransportControl<'_>,
    should_send: impl FnOnce(&PushAdvertisement) -> bool,
    progress: impl FnMut(&[u8]),
) -> Result<HttpPushOutcome, PushError> {
    let (format, limits) = (prepared.format, prepared.limits);
    send_advertised(
        remote,
        format,
        limits,
        control,
        |advertisement, offered| {
            // Retain the checked sender's capability validation before its boolean callback.
            offered.validate(&prepared, &prepared.commands, control.cancel)?;
            Ok(should_send(advertisement).then_some(prepared))
        },
        progress,
    )
    .await
}

/// Prepares a push from the live advertisement, then sends it in the same discovery's RPC.
///
/// `prepare` receives the validated advertisement before any POST; its preparation should exclude
/// [`PushAdvertisement::receiver_roots`]. `format` and `limits` bound advertisement parsing and
/// must match the prepared push's. Uses [`send_http_checked_with_progress`]'s error contract.
pub(crate) async fn send_http_preparing(
    remote: &HttpRemote,
    format: crate::ObjectFormat,
    limits: super::PushLimits,
    control: TransportControl<'_>,
    prepare: impl FnOnce(&PushAdvertisement) -> Result<PreparedPush, PushFailure>,
    progress: impl FnMut(&[u8]),
) -> Result<PushReport, PushError> {
    let outcome = send_advertised(
        remote,
        format,
        limits,
        control,
        |advertisement, _| prepare(advertisement).map(Some),
        progress,
    )
    .await?;
    match outcome {
        HttpPushOutcome::Sent(report) => Ok(report),
        HttpPushOutcome::Declined => unreachable!("preparing HTTP push cannot decline"),
    }
}

/// Discovers, lets `prepare` supply the push (or decline), validates capabilities and posts.
async fn send_advertised(
    remote: &HttpRemote,
    format: crate::ObjectFormat,
    limits: super::PushLimits,
    control: TransportControl<'_>,
    prepare: impl FnOnce(
        &PushAdvertisement,
        &protocol::AdvertisedCapabilities,
    ) -> Result<Option<PreparedPush>, PushFailure>,
    mut progress: impl FnMut(&[u8]),
) -> Result<HttpPushOutcome, PushError> {
    #[cfg(feature = "tracing")]
    let span = tracing::debug_span!(
        target: "girt",
        "push.http",
        outcome = "incomplete",
        failure_class = tracing::field::Empty,
        effects = tracing::field::Empty,
        accepted = tracing::field::Empty,
        rejected = tracing::field::Empty,
        pending = tracing::field::Empty,
        unpack = tracing::field::Empty,
    );

    let operation = async {
        let preflight = async {
            control.check()?;
            let (bytes, _) = remote
                .discover("git-receive-pack", limits.max_advertisement_bytes, control)
                .await?;
            let mut reader = bytes.as_slice();
            let mut wire = Wire {
                reader: &mut reader,
                remaining: limits.max_advertisement_bytes,
                cancel: control.cancel,
            };
            let (offered, advertisement) =
                protocol::advertise_for_selection(&mut wire, format, limits)?;
            wire.end()?;
            control.check()?;
            Ok::<_, PushFailure>((offered, advertisement))
        };
        let (offered, advertisement) = preflight.await.map_err(PushError::NotSent)?;
        let Some(mut prepared) = prepare(&advertisement, &offered).map_err(PushError::NotSent)?
        else {
            return Ok(HttpPushOutcome::Declined);
        };
        let caps = offered
            .validate(&prepared, &prepared.commands, control.cancel)
            .map_err(PushError::NotSent)?;
        if caps.report_v2 || caps.sideband {
            prepared.request = prepared
                .request_for(caps.report_v2, caps.sideband)
                .map_err(PushError::NotSent)?
                .into_owned();
        }
        let mut report = PushReport::pending(&prepared.commands);
        if prepared.commands.is_empty() {
            return Ok(HttpPushOutcome::Sent(report));
        }
        report.mark_attempted(&prepared.request, prepared.request.len());
        let body = RequestBody::new(prepared.request, prepared.pack)
            .map_err(|error| PushError::NotSent(error.into()))?;
        control.check().map_err(|e| PushError::NotSent(e.into()))?;
        let mut sideband = LiveProgress::default();
        let response = remote
            .exchange_observed(
                "git-receive-pack",
                Some(body),
                prepared.limits.max_status_bytes,
                control,
                |bytes| {
                    if caps.sideband {
                        sideband.observe(bytes, &mut progress);
                    }
                },
            )
            .await;
        // Parse already received evidence even when cancellation is set. This work is bounded by
        // the status byte budget; the network failure still determines the uncertain
        // outcome.
        let retain = AtomicBool::new(false);
        let mut reader = response.body.as_slice();
        let mut wire = Wire {
            reader: &mut reader,
            remaining: prepared.limits.max_status_bytes,
            cancel: &retain,
        };
        let parsed = protocol::read_response(
            &mut wire,
            &mut report,
            caps,
            prepared.limits.max_status_bytes,
        )
        .and_then(|()| wire.end().map_err(PushFailure::from));
        let result = response.result.map_err(PushFailure::from).and(parsed);
        match result {
            Ok(()) => Ok(HttpPushOutcome::Sent(report)),
            Err(cause) => Err(PushError::Uncertain {
                cause,
                report: Box::new(report),
            }),
        }
    };
    #[cfg(feature = "tracing")]
    let result = tracing::Instrument::instrument(operation, span.clone()).await;
    #[cfg(not(feature = "tracing"))]
    let result = operation.await;
    #[cfg(feature = "tracing")]
    crate::trace::finish(&span, &result, |error| crate::trace::push(error, &span));
    #[cfg(feature = "tracing")]
    if let Ok(HttpPushOutcome::Sent(report)) = &result {
        crate::trace::push_report(&span, report);
    }

    result
}