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)
}