Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-client/src/shallow.rs

//! The **client** half of shallow: `--depth`, `--deepen`, `--shallow-since`,
//! `--shallow-exclude` and `--unshallow`.
//!
//! # Why this module exists
//!
//! `gunnar-wire`'s upload-pack has served all five shallow wire arguments
//! (`shallow`, `deepen`, `deepen-relative`, `deepen-since`, `deepen-not`) since
//! the `shallow` capability was first advertised — advertising it is a promise to
//! accept all five, so they are implemented rather than rejected
//! (`gunnar_wire::shallow`). The client had **none** of them, in any layer:
//! `gunnar clone --depth 1` was `error: unexpected argument '--depth' found`.
//!
//! gunnar could therefore serve a shallow clone it could not make. That is the
//! shape a user meets first, because `actions/checkout` passes `--depth 1` by
//! default, so it is also the CI case.
//!
//! # What is reused
//!
//! The wire arguments themselves are **gix's**
//! (`gix_protocol::fetch::Shallow`, turned into
//! `deepen`/`deepen-since`/`deepen-not` lines by
//! `gix_protocol::fetch::add_shallow_args`, and the resulting `shallow`/
//! `unshallow` response lines written to `.git/shallow` by
//! `gix_shallow::write`). One implementation: nothing here re-encodes a wire
//! argument. [`Shallow`] is gix's own enum, re-exported.
//!
//! What is new is [`DepthArgs`] — the command line, and the refusals git makes
//! before a byte is sent.

use crate::error::{Error, Result};

/// gix's shallow request, re-exported: the vocabulary of the wire arguments
/// themselves.
///
/// Not wrapped. A newtype here would be a second name for the same five
/// variants, and the mapping onto `deepen`/`deepen-since`/`deepen-not` already
/// lives in `gix_protocol` where both this crate and `gix`'s own clone read it.
pub use gix::remote::fetch::Shallow;

/// The shallow-related flags of one `gunnar clone` or `gunnar fetch`.
///
/// Kept out of `gunnar-cli` on purpose: [`Self::to_shallow`] is where git's
/// refusals live, and a refusal that only exists in an argument parser cannot be
/// tested against the real `git` binary.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DepthArgs {
    /// `--depth <n>`: truncate to `n` commits **counted at the remote**, which
    /// is why it can also *shrink* an existing shallow clone.
    pub depth: Option<u32>,
    /// `--deepen <n>`: `n` more commits past the boundary **this** repository
    /// already has. `deepen` plus `deepen-relative` on the wire.
    pub deepen: Option<u32>,
    /// `--shallow-since <date>`: any git date, e.g. `@1700000000`,
    /// `2026-08-01`, `2.weeks.ago`.
    pub since: Option<String>,
    /// `--shallow-exclude <rev>`, repeatable. **Short names are the normal
    /// spelling** — git resolves them through `ref_rev_parse_rules`, so `side`
    /// means `refs/heads/side`, and the server is what resolves it.
    pub exclude: Vec<String>,
    /// `--unshallow`: fetch the whole history and delete `.git/shallow`.
    pub unshallow: bool,
}

impl DepthArgs {
    /// Did the caller ask for anything at all? A `false` here means the fetch
    /// must leave an existing boundary exactly where it is ([`Shallow::NoChange`]).
    pub fn is_active(&self) -> bool {
        self.depth.is_some()
            || self.deepen.is_some()
            || self.since.is_some()
            || !self.exclude.is_empty()
            || self.unshallow
    }

    /// Turn the flags into the wire request, refusing the combinations git
    /// refuses.
    ///
    /// # The refusals, and where each one comes from
    ///
    /// MEASURED with stock git 2.53.0 on 2026-08-05, on a three-commit
    /// repository cloned `--depth=1`:
    ///
    /// | command line | git 2.53.0 says |
    /// |---|---|
    /// | `--depth=0` | `fatal: depth 0 is not a positive number` |
    /// | `--depth=1 --unshallow` | `fatal: options '--depth' and '--unshallow' cannot be used together` |
    /// | `--deepen=1 --unshallow` | the same message (git folds `--deepen` into `--depth`) |
    /// | `--depth=1 --shallow-since=@…` | `fatal: git upload-pack: deepen and deepen-since (or deepen-not) cannot be used together` |
    /// | `--depth=1 --shallow-exclude=main` | the same message |
    /// | `--shallow-since=@… --shallow-exclude=main` | **accepted**, and answered by the server |
    ///
    /// The last two rows are the interesting ones. git the *client* sends
    /// `deepen` and `deepen-since` together and lets `upload-pack` die on them,
    /// so the refusal is the server's; here it is the client's, because
    /// [`Shallow`] has one variant per mode and cannot represent both at once.
    /// Refusing locally is strictly narrower than git — a request git would only
    /// ever get an error back for — and it is stated in git's own words so the
    /// operator reads one message, not two.
    ///
    /// `--shallow-since` together with `--shallow-exclude` **is** legal and both
    /// are sent, which is why [`Shallow::Exclude`] carries a `since_cutoff`.
    pub fn to_shallow(&self) -> Result<Shallow> {
        if self.unshallow {
            // Named `--depth` even for `--deepen`, because that is the name git
            // prints: `builtin/fetch.c` folds `--deepen` onto the same option.
            if self.depth.is_some() || self.deepen.is_some() {
                return Err(Error::Invalid(
                    "options '--depth' and '--unshallow' cannot be used together".into(),
                ));
            }
            if self.since.is_some() || !self.exclude.is_empty() {
                return Err(Error::Invalid(
                    "--unshallow asks for the whole history, so --shallow-since and \
                     --shallow-exclude cannot be used with it"
                        .into(),
                ));
            }
            // git's `--unshallow` is `deepen 2147483647`, not a wire argument of
            // its own. `Shallow::undo()` is that number.
            return Ok(Shallow::undo());
        }

        if self.depth.is_some() && self.deepen.is_some() {
            return Err(Error::Invalid(
                "options '--depth' and '--deepen' cannot be used together".into(),
            ));
        }

        let cutoff = match &self.since {
            Some(text) => Some(parse_since(text)?),
            None => None,
        };

        if let Some(depth) = self.depth.or(self.deepen) {
            if cutoff.is_some() || !self.exclude.is_empty() {
                // git's own words, from `upload-pack.c`. It is the server that
                // says this when git sends both; gunnar says it before sending.
                return Err(Error::Invalid(
                    "deepen and deepen-since (or deepen-not) cannot be used together".into(),
                ));
            }
            let depth = std::num::NonZeroU32::new(depth)
                .ok_or_else(|| Error::Invalid("depth 0 is not a positive number".into()))?;
            return Ok(if self.deepen.is_some() {
                // `--deepen` counts from OUR boundary, so `deepen` plus
                // `deepen-relative`. `--depth` counts at the remote and sends
                // `deepen` alone; conflating them makes `--deepen 1` on a
                // depth-1 clone fetch nothing at all.
                Shallow::Deepen(depth.get())
            } else {
                Shallow::DepthAtRemote(depth)
            });
        }

        if !self.exclude.is_empty() {
            let mut remote_refs = Vec::with_capacity(self.exclude.len());
            for name in &self.exclude {
                remote_refs.push(parse_exclude(name)?);
            }
            return Ok(Shallow::Exclude {
                remote_refs,
                since_cutoff: cutoff,
            });
        }

        match cutoff {
            Some(cutoff) => Ok(Shallow::Since { cutoff }),
            None => Ok(Shallow::NoChange),
        }
    }
}

/// Parse a `--shallow-since` value the way git does.
///
/// # `@<seconds>` is not gix's, and it is the one every script uses
///
/// git's approxidate accepts a leading `@` for "these are literal seconds since
/// the epoch" — `git log --since=@1700000000`, `git commit --date=@…` — and it is
/// the only spelling that is not locale- or clock-dependent, so it is what a
/// fixture or a cron job writes. `gix_date::parse` does not implement it:
/// MEASURED 2026-08-05, `gix::date::parse("@1700001500", …)` returns
/// *Unknown date format: "@1700001500"*.
///
/// So `@<seconds>` and a bare `<seconds>` are handled here and everything else
/// is handed to gix, which covers RFC2822, ISO8601, the raw `<seconds> <offset>`
/// form and the relative one. Written this way round rather than as a second date
/// parser: two lines of special case, not a re-implementation.
fn parse_since(text: &str) -> Result<gix::date::Time> {
    let bare = text.strip_prefix('@').unwrap_or(text);
    // `@<seconds>` and `@<seconds> <offset>`, which is what git's `parse_date`
    // does with a leading `@`.
    let (secs, offset) = match bare.split_once(' ') {
        Some((secs, offset)) => (secs.trim(), Some(offset.trim())),
        None => (bare.trim(), None),
    };
    if let Ok(seconds) = secs.parse::<i64>() {
        let offset = match offset {
            Some(text) => parse_tz_offset(text).ok_or_else(|| {
                Error::Invalid(format!(
                    "--shallow-since {text:?} is not a timezone offset like +0200"
                ))
            })?,
            None => 0,
        };
        return Ok(gix::date::Time::new(seconds, offset));
    }
    gix::date::parse(text, Some(gix::date::Zoned::now())).map_err(|e| {
        Error::Invalid(format!(
            "--shallow-since {text:?} is not a date git understands ({e}); try @<unix seconds>, \
             an ISO date, or `2.weeks.ago`"
        ))
    })
}

/// `+0200` / `-0530` as seconds east of UTC.
fn parse_tz_offset(text: &str) -> Option<i32> {
    let (sign, digits) = match text.as_bytes().first()? {
        b'+' => (1, &text[1..]),
        b'-' => (-1, &text[1..]),
        _ => (1, text),
    };
    if digits.len() != 4 || !digits.bytes().all(|b| b.is_ascii_digit()) {
        return None;
    }
    let hours: i32 = digits[..2].parse().ok()?;
    let minutes: i32 = digits[2..].parse().ok()?;
    Some(sign * (hours * 3600 + minutes * 60))
}

/// Parse one `--shallow-exclude` value.
///
/// A **partial** name on purpose. `deepen-not` takes a revision and the server
/// expands it through `ref_rev_parse_rules`, so `side` is the ordinary spelling
/// and the one gunnar's own interop fixtures are written in. Requiring
/// `refs/heads/side` here would be the client-side twin of the server bug fixed
/// on 2026-08-05, where `FullName::new` rejected the short form and the
/// deepening then ran with no exclusion at all: 5 commits and an empty
/// `.git/shallow` where stock git gave 2 commits and 2 cut points.
fn parse_exclude(name: &str) -> Result<gix::refs::PartialName> {
    use std::convert::TryFrom;
    gix::refs::PartialName::try_from(name).map_err(|e| {
        Error::Invalid(format!(
            "--shallow-exclude {name:?} is not a usable ref name ({e})"
        ))
    })
}

/// Where a repository's history is cut, read back **from `.git/shallow`**.
///
/// Applied output, not intent (measure, never assert). A fetch that asked for
/// `--depth 1` and received the whole history looks identical to a working one
/// from the request's side; the file is the only place the difference shows.
/// Sorted, so two runs compare.
pub fn boundary(repo: &gix::Repository) -> Result<Vec<gix::hash::ObjectId>> {
    let commits = repo
        .shallow_commits()
        .map_err(|e| Error::repo("read .git/shallow", e))?;
    let mut ids: Vec<_> = commits
        .map(|c| c.iter().copied().collect::<Vec<_>>())
        .unwrap_or_default();
    ids.sort();
    Ok(ids)
}