girt 0.1.1

An incremental, idiomatic Rust implementation of Git
Documentation
use std::path::PathBuf;

use super::store::{malformed, read_optional};
use super::{RefName, ReferenceError, References};
use crate::{ObjectId, Signature};

/// One format-bearing reflog record, in oldest-to-newest file order.
///
/// Zero IDs represent absence. Parsing preserves message and email bytes, trims name delimiter
/// whitespace, and normalizes numeric timestamps/timezones. Use [`ReflogRecord`] to retain the
/// original lexical record. An omitted message separator maps to
/// an empty message. Object existence and continuity
/// between records are not checked.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReflogEntry {
    /// Previous object ID, or zero for creation.
    pub old: ObjectId,
    /// New object ID, or zero for deletion.
    pub new: ObjectId,
    /// Caller-supplied identity, Unix seconds and timezone.
    pub committer: Signature,
    /// Message bytes without the files separator or final newline. Imported binary records may
    /// retain embedded control bytes.
    pub message: Vec<u8>,
}

/// A parsed imported record with its exact original bytes, including the final newline.
///
/// Interpretation may normalize name padding and numeric dates. Retain this value when copying
/// history: constructing a new entry applies stricter canonical identity and date rules.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReflogRecord {
    bytes: Vec<u8>,
    entry: ReflogEntry,
}

impl ReflogRecord {
    /// Parses a complete log and retains every record byte.
    ///
    /// # Errors
    ///
    /// Returns the framing and numeric limits documented by [`ReflogEntry::parse`].
    pub fn parse(format: crate::ObjectFormat, bytes: &[u8]) -> Result<Vec<Self>, ReferenceError> {
        let entries = ReflogEntry::parse(format, bytes)?;
        Ok(bytes
            .split_inclusive(|b| *b == b'\n')
            .zip(entries)
            .map(|(bytes, entry)| Self {
                bytes: bytes.to_vec(),
                entry,
            })
            .collect())
    }

    /// Returns the exact imported record, including delimiters and newline.
    pub fn as_bytes(&self) -> &[u8] {
        &self.bytes
    }

    /// Returns interpreted fields without applying construction validation.
    pub fn entry(&self) -> &ReflogEntry {
        &self.entry
    }
}

/// Explicit policy for every name affected by an operation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Reflog {
    /// Leave existing logs unchanged and create none, including on deletion.
    Preserve,
    /// Remove the edited name's log after deleting its reference. Valid only for deletion.
    /// Resolved deletion removes only the terminal log, preserving symbolic aliases' logs.
    Delete,
    /// Create a missing log or append to an existing log, even for an unchanged ID.
    ///
    /// Resolved operations log every name in the symbolic chain, using the terminal old/new IDs.
    /// Deletion appends a zero new ID and retains the file. Environment, hooks and Git config are
    /// never consulted. Existing files bytes are preserved without interpreting older records.
    /// A nonempty unterminated tail refuses preparation so the new record cannot be hidden inside
    /// an earlier message; the caller must inspect and repair it explicitly. Stored symbolic edits
    /// log only the edited name, resolving old and new terminal IDs under locks.
    Append {
        /// Validated like commit construction, additionally requiring nonnegative seconds.
        committer: Signature,
        /// Exact bytes; NUL, CR and LF are rejected. No whitespace normalization is performed.
        message: Vec<u8>,
    },
}

impl ReflogEntry {
    /// Parses a complete newline-terminated reflog, returning records in file order.
    ///
    /// The caller selects the repository format, including for empty input; every ID must match.
    /// Imported names may be empty or padded; trailing name whitespace is trimmed in the
    /// interpreted value. Signed `i64` seconds and four-digit signed zones are accepted, including
    /// noncanonical minutes such as `+0060`. UTF-8 is not required. Messages may contain tabs.
    /// Use [`ReflogRecord`] for exact lexical retention. Short zones, suffix text, and seconds
    /// outside `i64` are explicit reader limits; they are not claims of Git invalidity.
    ///
    /// # Errors
    ///
    /// Rejects malformed or foreign-format IDs, identity/date framing, invalid identities, NUL/CR
    /// messages, and truncated records. Errors identify the format rule; filesystem readers add
    /// the log path.
    pub fn parse(format: crate::ObjectFormat, bytes: &[u8]) -> Result<Vec<Self>, ReferenceError> {
        parse(format, bytes, &std::path::PathBuf::from("<reflog>"))
    }

    pub(super) fn encode(&self) -> Result<Vec<u8>, ReferenceError> {
        self.new.require_format(self.old.format())?;
        validate(&self.committer, &self.message)?;
        let mut bytes = format!("{} {}", self.old, self.new).into_bytes();
        self.committer.encode(b"", &mut bytes);
        bytes.pop();
        bytes.push(b'\t');
        bytes.extend_from_slice(&self.message);
        bytes.push(b'\n');
        Ok(bytes)
    }
}

impl References<'_> {
    /// Reports whether a reflog exists without reading its complete history.
    ///
    /// Files storage rejects symlinks and non-regular log paths. Reftable reads a bounded
    /// snapshot; absence means no live log records. This is a live observation, not a lock or a
    /// promise that a subsequent append will succeed.
    ///
    /// # Errors
    ///
    /// Returns unsafe path, filesystem or reftable snapshot failures.
    pub fn has_reflog(&self, name: &RefName) -> Result<bool, ReferenceError> {
        if self.repository.reference_backend() == super::Backend::Reftable {
            return Ok(super::reftable::backend::reflog(self, name)?.is_some());
        }
        let path = self.reflog_path(name)?;
        super::store::check_path(&path)?;
        match std::fs::symlink_metadata(&path) {
            Ok(_) => Ok(true),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
            Err(error) => Err(super::store::io_error(&path, error)),
        }
    }

    /// Reads a complete reflog in oldest-to-newest order, or returns `None` if absent.
    ///
    /// Reftable uses a bounded snapshot, omits tombstones and strips one trailing message newline.
    /// It returns `None` when no live log records remain. Unsigned timestamps beyond `i64` fail
    /// interpretation; use [`super::reftable::Snapshot`] for lossless access. The remaining
    /// live-file details below describe the files backend.
    ///
    /// Uses the same worktree routing as references. Reads are live and may see an incomplete
    /// append by another writer. Prefer [`Self::imported_reflog`] for bounded recoverable reads.
    /// Memory is proportional to the whole log; this reader does not stream records or expire
    /// history. [`crate::Repository::expire_reflogs`] separately expires eligible files or
    /// reftable records under caller-owned maintenance isolation on Unix.
    ///
    /// # Errors
    ///
    /// Reports filesystem, symlink, and malformed-record errors, including a truncated tail.
    pub fn reflog(&self, name: &RefName) -> Result<Option<Vec<ReflogEntry>>, ReferenceError> {
        if self.repository.reference_backend() == super::Backend::Reftable {
            return super::reftable::backend::reflog(self, name);
        }
        let path = self.reflog_path(name)?;
        read_optional(&path)?
            .map(|bytes| parse(self.repository.object_format(), &bytes, &path))
            .transpose()
    }

    pub(crate) fn reflog_path(&self, name: &RefName) -> Result<PathBuf, ReferenceError> {
        let path = self.path(name)?;
        let root = if name.per_worktree() {
            self.repository.git_dir()
        } else {
            self.repository.common_dir()
        };
        Ok(root.join("logs").join(path.strip_prefix(root).unwrap()))
    }
}

// Inspect only framing at the append boundary; imported grammar does not constrain construction.
pub(super) fn check_append_tail(path: &std::path::Path) -> Result<(), ReferenceError> {
    use std::io::{Read, Seek, SeekFrom};
    super::store::check_path(path)?;
    let mut file = match std::fs::File::open(path) {
        Ok(file) => file,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(error) => return Err(super::store::io_error(path, error)),
    };
    let size = file
        .seek(SeekFrom::End(0))
        .map_err(|error| super::store::io_error(path, error))?;
    if size == 0 {
        return Ok(());
    }
    file.seek(SeekFrom::End(-1))
        .map_err(|error| super::store::io_error(path, error))?;
    let mut last = [0];
    file.read_exact(&mut last)
        .map_err(|error| super::store::io_error(path, error))?;
    if last[0] != b'\n' {
        return Err(malformed(
            path,
            "unterminated reflog append tail; inspect and repair before retry",
        ));
    }
    Ok(())
}

pub(super) fn validate(committer: &Signature, message: &[u8]) -> Result<(), ReferenceError> {
    if committer.name.contains(&b'\t') || committer.email.contains(&b'\t') {
        return Err(ReferenceError::Unsupported("tab in reflog identity"));
    }
    // Reflog policy is narrower than commit/tag timestamp construction.
    if committer.seconds < 0 {
        return Err(ReferenceError::Unsupported("negative reflog timestamp"));
    }
    committer
        .validate()
        .map_err(|_| ReferenceError::Unsupported("invalid reflog identity or date"))?;
    if message.iter().any(|b| matches!(b, 0 | b'\r' | b'\n')) {
        return Err(ReferenceError::Unsupported(
            "multiline or NUL reflog message",
        ));
    }
    Ok(())
}

pub(super) fn parse(
    format: crate::ObjectFormat,
    bytes: &[u8],
    path: &std::path::Path,
) -> Result<Vec<ReflogEntry>, ReferenceError> {
    if bytes.is_empty() {
        return Ok(Vec::new());
    }
    let body = bytes
        .strip_suffix(b"\n")
        .ok_or_else(|| malformed(path, "unterminated reflog record"))?;
    body.split(|b| *b == b'\n')
        .map(|line| parse_line(format, line, path))
        .collect()
}

fn parse_line(
    format: crate::ObjectFormat,
    line: &[u8],
    path: &std::path::Path,
) -> Result<ReflogEntry, ReferenceError> {
    let width = format.digest_len() * 2;
    if line.get(width) != Some(&b' ') || line.get(2 * width + 1) != Some(&b' ') {
        return Err(malformed(path, "expected two repository-format reflog IDs"));
    }
    let parse_id = |bytes| {
        std::str::from_utf8(bytes)
            .ok()
            .and_then(|s| ObjectId::from_hex(format, s).ok())
            .ok_or_else(|| malformed(path, "invalid reflog ID"))
    };
    let old = parse_id(&line[..width])?;
    let new = parse_id(&line[width + 1..2 * width + 1])?;
    let rest = &line[2 * width + 2..];
    let tab = rest.iter().position(|b| *b == b'\t').unwrap_or(rest.len());
    let identity = &rest[..tab];
    let date = crate::IdentityRef::parse(identity)
        .map_err(|_| malformed(path, "invalid reflog identity"))?
        .date_bytes;
    let zone = date
        .trim_ascii_start()
        .splitn(2, |&b| b == b' ')
        .nth(1)
        .unwrap_or_default();
    if !date.first().is_some_and(u8::is_ascii_whitespace)
        || zone.len() != 5
        || !matches!(zone[0], b'+' | b'-')
        || !zone[1..].iter().all(u8::is_ascii_digit)
    {
        return Err(malformed(path, "invalid reflog date framing"));
    }
    let committer = crate::IdentityRef::parse(identity)
        .and_then(crate::IdentityRef::signature)
        .map_err(|_| {
            malformed(
                path,
                "reflog identity or numeric date exceeds reader limits",
            )
        })?;
    let message = rest.get(tab + 1..).unwrap_or_default().to_vec();
    if identity.contains(&0)
        || identity.contains(&b'\r')
        || message.iter().any(|b| matches!(b, 0 | b'\r'))
    {
        return Err(malformed(path, "NUL or CR in reflog record"));
    }
    Ok(ReflogEntry {
        old,
        new,
        committer,
        message,
    })
}

#[cfg(test)]
mod tests {
    use rstest::rstest;

    use super::*;

    fn record(tail: &[u8]) -> Vec<u8> {
        let mut bytes =
            format!("{} {} ", ObjectId::Sha1([0; 20]), ObjectId::Sha1([1; 20])).into_bytes();
        bytes.extend_from_slice(tail);
        bytes
    }

    #[rstest]
    #[case::truncated(b"A <a@b> 1 +0000\tmessage")]
    #[case::cr_name(b"A\r <a@b> 1 +0000\tmessage\n")]
    #[case::nul(b"A <a@b> 1 +0000\tx\0y\n")]
    #[case::cr(b"A <a@b> 1 +0000\tx\ry\n")]
    #[case::missing_separator(b"A <a@b> 1 +0000 message\n")]
    fn rejects_malformed_records(#[case] tail: &[u8]) {
        assert!(matches!(
            ReflogEntry::parse(crate::ObjectFormat::Sha1, &record(tail)),
            Err(ReferenceError::Malformed { .. })
        ));
    }

    #[test]
    fn parses_byte_identity_and_message_without_utf8() {
        let bytes = record(b"A\xff <a@b> 123 -0330\tm\xff\tend\n");
        let entries = ReflogEntry::parse(crate::ObjectFormat::Sha1, &bytes).unwrap();
        assert_eq!(entries[0].committer.name, b"A\xff");
        assert_eq!(entries[0].committer.offset_minutes, -210);
        assert_eq!(entries[0].encode().unwrap(), bytes);
    }

    #[test]
    fn rejects_bad_ids() {
        let mut bytes = record(b"A <a@b> 1 +0000\tm\n");
        bytes[0] = b'z';
        assert!(ReflogEntry::parse(crate::ObjectFormat::Sha1, &bytes).is_err());
    }
}

#[cfg(test)]
mod format_tests {
    use rstest::rstest;

    use super::*;
    use crate::ObjectFormat;

    #[rstest]
    #[case::sha1(ObjectFormat::Sha1, ObjectFormat::Sha256)]
    #[case::sha256(ObjectFormat::Sha256, ObjectFormat::Sha1)]
    fn null_records_use_the_selected_format(
        #[case] format: ObjectFormat,
        #[case] other: ObjectFormat,
    ) {
        let bytes = format!(
            "{} {} A <a@b> 1 +0000\tmessage\n",
            ObjectId::null(format),
            ObjectId::null(format)
        );
        let records = ReflogEntry::parse(format, bytes.as_bytes()).unwrap();
        assert_eq!(records[0].old, ObjectId::null(format));
        assert_eq!(records[0].new, ObjectId::null(format));
        assert_eq!(records[0].encode().unwrap(), bytes.as_bytes());
        assert!(ReflogEntry::parse(other, bytes.as_bytes()).is_err());
        assert!(ReflogEntry::parse(format, b"").unwrap().is_empty());
        let mixed = ReflogEntry {
            new: ObjectId::null(other),
            ..records[0].clone()
        };
        assert!(matches!(
            mixed.encode(),
            Err(ReferenceError::ObjectFormat(_))
        ));
    }
}