use std::collections::{HashSet, VecDeque};
use std::fmt;
use std::io;
use std::io::Write as _;
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use std::string::FromUtf8Error;
use std::sync::atomic::AtomicBool;
pub(crate) mod branch;
use gix::Repository;
use gix::bstr::{BStr, ByteSlice};
use gix::config::file::Metadata as GixConfigMetadata;
use gix::config::file::init as gix_config_init;
use gix::config::parse::section::{
ValueName, header as gix_section_header, value_name as gix_value_name,
};
use gix::lock as gix_lock;
use gix::progress::Discard;
use gix::remote::Direction;
use gix_hash::ObjectId;
use thiserror::Error;
use tracing::debug;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Sha(ObjectId);
impl Sha {
pub fn from_hex(hex: &str) -> Result<Self, ShaError> {
if hex.is_empty() {
return Err(ShaError::Empty);
}
Ok(Sha(ObjectId::from_hex(hex.as_bytes())?))
}
#[must_use]
pub fn from_object_id(id: ObjectId) -> Self {
Sha(id)
}
#[must_use]
pub fn as_object_id(&self) -> &ObjectId {
&self.0
}
}
impl fmt::Display for Sha {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Error)]
pub enum ShaError {
#[error("expected hex digits, got empty string")]
Empty,
#[error(transparent)]
Decode(#[from] gix_hash::decode::Error),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RefName(String);
impl RefName {
pub fn new(name: impl Into<String>) -> Result<Self, RefNameError> {
let name = name.into();
match gix_validate::reference::name(BStr::new(&name)) {
Ok(_) => Ok(RefName(name)),
Err(source) => Err(RefNameError::Invalid { name, source }),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn is_valid(name: &str) -> bool {
gix_validate::reference::name(BStr::new(name)).is_ok()
}
}
impl fmt::Display for RefName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for RefName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<RefName> for String {
fn from(value: RefName) -> Self {
value.0
}
}
#[derive(Debug, Error)]
pub enum RefNameError {
#[error("invalid ref name {name:?}: {source}")]
Invalid {
name: String,
#[source]
source: gix_validate::reference::name::Error,
},
}
#[must_use]
pub fn is_valid_ref_name(name: &str) -> bool {
gix_validate::reference::name_partial(BStr::new(name)).is_ok()
}
#[derive(Debug, Error)]
pub enum GitError {
#[error("rev-spec is empty")]
EmptySpec,
#[error("repository has no commits")]
NoCommits,
#[error("remote not found: {0}")]
RemoteNotFound(String),
#[error("remote has no fetch or push URL: {0}")]
RemoteHasNoUrl(String),
#[error("remote {remote} URL is not valid UTF-8")]
NonUtf8RemoteUrl {
remote: String,
#[source]
source: FromUtf8Error,
},
#[error("bundle: {0}")]
Bundle(Box<crate::bundle::BundleError>),
#[error("blocking task panicked")]
Panic(#[from] tokio::task::JoinError),
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
RevParse(#[from] gix::revision::spec::parse::single::Error),
#[error(transparent)]
FindObject(#[from] gix::object::find::existing::Error),
#[error(transparent)]
PeelToKind(#[from] gix::object::peel::to_kind::Error),
#[error(transparent)]
HeadCommit(#[from] gix::reference::head_commit::Error),
#[error(transparent)]
DecodeCommit(#[from] gix::objs::decode::Error),
#[error(transparent)]
ShortId(#[from] gix::id::shorten::Error),
#[error(transparent)]
MergeBase(Box<gix::repository::merge_base::Error>),
#[error(transparent)]
WorktreeStream(Box<gix::repository::worktree_stream::Error>),
#[error(transparent)]
WorktreeArchive(Box<gix::repository::worktree_archive::Error>),
#[error(transparent)]
FindRemote(Box<gix::remote::find::existing::Error>),
#[error(transparent)]
Open(Box<gix::open::Error>),
#[error(transparent)]
Discover(Box<gix::discover::Error>),
#[error("invalid config key {0:?}: must be of the form <section>[.<subsection>].<name>")]
ConfigKeyParse(String),
#[error("invalid config section name {name:?}: {source}")]
ConfigInvalidSectionName {
name: String,
#[source]
source: gix_section_header::Error,
},
#[error("invalid config value name {name:?}: {source}")]
ConfigInvalidValueName {
name: String,
#[source]
source: gix_value_name::Error,
},
#[error("config key not set: {0}")]
ConfigKeyNotSet(String),
#[error(transparent)]
ConfigParse(Box<gix_config_init::Error>),
#[error(transparent)]
ConfigLock(Box<gix_lock::acquire::Error>),
#[error("tag chain contains a cycle at {oid}")]
TagChainCycle {
oid: ObjectId,
},
}
impl From<gix::open::Error> for GitError {
fn from(e: gix::open::Error) -> Self {
GitError::Open(Box::new(e))
}
}
impl From<gix::repository::merge_base::Error> for GitError {
fn from(e: gix::repository::merge_base::Error) -> Self {
GitError::MergeBase(Box::new(e))
}
}
impl From<gix::repository::worktree_stream::Error> for GitError {
fn from(e: gix::repository::worktree_stream::Error) -> Self {
GitError::WorktreeStream(Box::new(e))
}
}
impl From<gix::repository::worktree_archive::Error> for GitError {
fn from(e: gix::repository::worktree_archive::Error) -> Self {
GitError::WorktreeArchive(Box::new(e))
}
}
impl From<gix::remote::find::existing::Error> for GitError {
fn from(e: gix::remote::find::existing::Error) -> Self {
GitError::FindRemote(Box::new(e))
}
}
impl From<gix::discover::Error> for GitError {
fn from(e: gix::discover::Error) -> Self {
GitError::Discover(Box::new(e))
}
}
impl From<gix_config_init::Error> for GitError {
fn from(e: gix_config_init::Error) -> Self {
GitError::ConfigParse(Box::new(e))
}
}
impl From<gix_lock::acquire::Error> for GitError {
fn from(e: gix_lock::acquire::Error) -> Self {
GitError::ConfigLock(Box::new(e))
}
}
fn repo_cwd(repo: &Repository) -> &Path {
repo.workdir().unwrap_or_else(|| repo.git_dir())
}
pub async fn bundle(
repo: &Repository,
folder: &Path,
sha: Sha,
spec: &str,
) -> Result<PathBuf, GitError> {
let cwd = repo_cwd(repo).to_owned();
bundle_at(&cwd, folder, sha, spec).await
}
pub async fn bundle_at(
cwd: &Path,
folder: &Path,
sha: Sha,
spec: &str,
) -> Result<PathBuf, GitError> {
let (cwd, folder, spec) = (cwd.to_owned(), folder.to_owned(), spec.to_owned());
tokio::task::spawn_blocking(move || crate::bundle::create(&cwd, &folder, sha, &spec))
.await?
.map_err(|e| GitError::Bundle(Box::new(e)))
}
pub async fn unbundle(repo: &Repository, folder: &Path, sha: Sha) -> Result<(), GitError> {
unbundle_at(repo_cwd(repo), folder, sha).await
}
pub async fn unbundle_at(cwd: &Path, folder: &Path, sha: Sha) -> Result<(), GitError> {
let (cwd, folder) = (cwd.to_owned(), folder.to_owned());
tokio::task::spawn_blocking(move || crate::bundle::unbundle(&cwd, &folder, sha))
.await?
.map_err(|e| GitError::Bundle(Box::new(e)))
}
pub fn is_ancestor(repo: &Repository, ancestor: Sha, descendant: Sha) -> Result<bool, GitError> {
if ancestor == descendant {
return Ok(true);
}
let ancestor_oid = *ancestor.as_object_id();
let descendant_oid = *descendant.as_object_id();
match repo.merge_base(ancestor_oid, descendant_oid) {
Ok(base) => Ok(base.detach() == ancestor_oid),
Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(false),
Err(e) => Err(e.into()),
}
}
pub(crate) enum PeeledTip {
Commit {
commit: Sha,
tag_chain: Vec<ObjectId>,
},
Tree {
tree: ObjectId,
tag_chain: Vec<ObjectId>,
},
Blob {
blob: ObjectId,
tag_chain: Vec<ObjectId>,
},
}
pub(crate) fn peel_tag_chain(repo: &Repository, tip: Sha) -> Result<PeeledTip, GitError> {
let mut visited: HashSet<ObjectId> = HashSet::new();
let mut tag_chain = Vec::new();
let mut current = *tip.as_object_id();
loop {
if !visited.insert(current) {
return Err(GitError::TagChainCycle { oid: current });
}
let object = repo.find_object(current)?;
match object.kind {
gix::object::Kind::Commit => {
return Ok(PeeledTip::Commit {
commit: Sha::from_object_id(current),
tag_chain,
});
}
gix::object::Kind::Tag => {
tag_chain.push(current);
current = object.into_tag().target_id()?.detach();
}
gix::object::Kind::Tree => {
return Ok(PeeledTip::Tree {
tree: current,
tag_chain,
});
}
gix::object::Kind::Blob => {
return Ok(PeeledTip::Blob {
blob: current,
tag_chain,
});
}
}
}
}
pub(crate) fn shallow_boundaries(
repo: &Repository,
tip: Sha,
max_depth: NonZeroU32,
) -> Result<Vec<ObjectId>, GitError> {
let max_depth = max_depth.get();
let tip_oid = match peel_tag_chain(repo, tip)? {
PeeledTip::Commit { commit, .. } => *commit.as_object_id(),
PeeledTip::Tree { .. } | PeeledTip::Blob { .. } => {
tracing::warn!(
tip = %tip,
"shallow fetch (--depth=N) is not meaningful for non-commit-tipped refs; \
falling back to full fetch (no .git/shallow markers written)",
);
return Ok(Vec::new());
}
};
let mut seen: HashSet<ObjectId> = HashSet::new();
let mut frontier: Vec<ObjectId> = Vec::new();
let mut queue: VecDeque<(ObjectId, u32)> = VecDeque::new();
queue.push_back((tip_oid, 1));
while let Some((oid, depth)) = queue.pop_front() {
if !seen.insert(oid) {
continue;
}
if depth == max_depth {
frontier.push(oid);
continue;
}
let commit = repo
.find_object(oid)?
.peel_to_kind(gix::object::Kind::Commit)?;
let commit = commit.into_commit();
for parent in commit.parent_ids() {
let parent_oid = parent.detach();
if !seen.contains(&parent_oid) {
queue.push_back((parent_oid, depth + 1));
}
}
}
Ok(frontier)
}
const SHA1_HEX_LINE_LEN: usize = 41;
pub(crate) fn write_shallow_file(repo_dir: &Path, boundaries: &[ObjectId]) -> Result<(), GitError> {
let path = git_dir_for(repo_dir).join("shallow");
let mut existing: HashSet<ObjectId> = HashSet::new();
for line in read_or_empty(&path)?.split(|&b| b == b'\n') {
let line = line.trim_ascii();
if !line.is_empty()
&& let Ok(oid) = ObjectId::from_hex(line)
{
existing.insert(oid);
}
}
let mut final_set: HashSet<ObjectId> = boundaries.iter().copied().collect();
existing.retain(|oid| !final_set.contains(oid));
let stale = existing;
if !stale.is_empty() {
let repo = gix::open(repo_dir).map_err(|e| GitError::Open(Box::new(e)))?;
let odb = repo.objects.clone().into_inner();
for oid in stale {
if entry_remains_a_boundary(&repo, &odb, oid) {
final_set.insert(oid);
}
}
}
if final_set.is_empty() {
if let Err(e) = std::fs::remove_file(&path)
&& e.kind() != io::ErrorKind::NotFound
{
return Err(GitError::Io(e));
}
return Ok(());
}
let mut sorted: Vec<ObjectId> = final_set.into_iter().collect();
sorted.sort_unstable();
let mut buf = Vec::with_capacity(sorted.len() * SHA1_HEX_LINE_LEN);
for oid in &sorted {
writeln!(buf, "{}", oid.to_hex()).map_err(GitError::Io)?;
}
write_atomic(&path, &buf)
}
fn git_dir_for(repo_dir: &Path) -> PathBuf {
let candidate = repo_dir.join(".git");
if candidate.is_dir() {
return candidate;
}
if candidate.is_file()
&& let Ok(content) = std::fs::read_to_string(&candidate)
&& let Some(rest) = content.trim().strip_prefix("gitdir:")
{
let pointed = Path::new(rest.trim());
let resolved = if pointed.is_absolute() {
pointed.to_path_buf()
} else {
repo_dir.join(pointed)
};
if resolved.is_dir() {
return resolved;
}
}
repo_dir.to_path_buf()
}
fn entry_remains_a_boundary(
repo: &gix::Repository,
odb: &impl gix_pack::Find,
oid: ObjectId,
) -> bool {
let object = match repo.find_object(oid) {
Ok(o) => o,
Err(e) => {
debug!(%oid, error = %e, "shallow entry not found in ODB; pruning");
return false;
}
};
let commit = match object.peel_to_kind(gix::object::Kind::Commit) {
Ok(c) => c.into_commit(),
Err(e) => {
debug!(%oid, error = %e, "shallow entry does not peel to a commit; pruning");
return false;
}
};
let mut parents = commit.parent_ids().map(gix::Id::detach).peekable();
if parents.peek().is_none() {
return false;
}
parents.any(|p| !odb.contains(&p))
}
pub fn archive(repo: &Repository, folder: &Path, spec: &str) -> Result<PathBuf, GitError> {
let tree = repo
.rev_parse_single(BStr::new(spec))?
.object()?
.peel_to_kind(gix::object::Kind::Tree)?;
let (stream, _index) = repo.worktree_stream(tree.id)?;
let path = folder.join("repo.zip");
let file = std::fs::File::create(&path)?;
let buf = std::io::BufWriter::new(file);
let interrupt = AtomicBool::new(false);
let options = gix_archive::Options {
format: gix_archive::Format::Zip {
compression_level: None,
},
..gix_archive::Options::default()
};
repo.worktree_archive(stream, buf, Discard, &interrupt, options)?;
Ok(path)
}
pub fn last_commit_message(repo: &Repository) -> Result<String, GitError> {
use gix::head::peel;
let commit = match repo.head_commit() {
Ok(c) => c,
Err(gix::reference::head_commit::Error::PeelToCommit(
peel::to_commit::Error::PeelToObject(peel::to_object::Error::Unborn { .. }),
)) => return Err(GitError::NoCommits),
Err(e) => return Err(e.into()),
};
let short = commit.short_id()?;
let message = commit.message()?;
Ok(format!("{} {}", short, message.summary().to_str_lossy()))
}
pub fn remote_url(repo: &Repository, name: &str) -> Result<String, GitError> {
let owned_name = || name.to_owned();
let remote = repo.find_remote(BStr::new(name)).map_err(|e| match e {
gix::remote::find::existing::Error::NotFound { .. } => {
GitError::RemoteNotFound(owned_name())
}
other => GitError::FindRemote(Box::new(other)),
})?;
let url = remote
.url(Direction::Fetch)
.or_else(|| remote.url(Direction::Push))
.ok_or_else(|| GitError::RemoteHasNoUrl(owned_name()))?;
String::from_utf8(url.to_bstring().into()).map_err(|source| GitError::NonUtf8RemoteUrl {
remote: owned_name(),
source,
})
}
struct DottedKey<'a> {
section: &'a str,
subsection: Option<&'a str>,
name: &'a str,
}
fn parse_dotted_key(key: &str) -> Result<DottedKey<'_>, GitError> {
let first_dot = key
.find('.')
.ok_or_else(|| GitError::ConfigKeyParse(key.to_owned()))?;
let last_dot = key
.rfind('.')
.expect("first_dot found, so rfind cannot be None");
let section = &key[..first_dot];
let name = &key[last_dot + 1..];
if section.is_empty() || name.is_empty() {
return Err(GitError::ConfigKeyParse(key.to_owned()));
}
let subsection = (first_dot != last_dot).then(|| &key[first_dot + 1..last_dot]);
Ok(DottedKey {
section,
subsection,
name,
})
}
fn config_path_for_cwd(cwd: &Path) -> Result<PathBuf, GitError> {
let repo = gix::discover(cwd)?;
Ok(repo.common_dir().join("config"))
}
fn read_or_empty(path: &Path) -> Result<Vec<u8>, GitError> {
match std::fs::read(path) {
Ok(bytes) => Ok(bytes),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(GitError::Io(e)),
}
}
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), GitError> {
use std::io::Write;
let mut lock = gix_lock::File::acquire_to_update_resource(
path,
gix_lock::acquire::Fail::Immediately,
None,
)?;
lock.write_all(bytes).map_err(GitError::Io)?;
lock.commit().map_err(|e| GitError::Io(e.error))?;
Ok(())
}
pub fn config_add(cwd: &Path, key: &str, value: &str) -> Result<(), GitError> {
config_add_many(cwd, &[(key, value)])
}
pub fn config_add_many(cwd: &Path, entries: &[(&str, &str)]) -> Result<(), GitError> {
apply_config_entries(cwd, entries, |file, parsed| {
for (parts, value_name, value) in parsed {
let subsection = parts.subsection.map(BStr::new);
let mut section = file
.section_mut_or_create_new(parts.section, subsection)
.map_err(|source| GitError::ConfigInvalidSectionName {
name: parts.section.to_owned(),
source,
})?;
section.push(value_name.clone(), Some(BStr::new(value)));
}
Ok(true)
})
}
fn apply_config_entries<F>(cwd: &Path, entries: &[(&str, &str)], mutate: F) -> Result<(), GitError>
where
F: for<'a> FnOnce(
&mut gix::config::File<'a>,
&[(DottedKey<'a>, ValueName<'a>, &'a str)],
) -> Result<bool, GitError>,
{
if entries.is_empty() {
return Ok(());
}
let parsed: Vec<(DottedKey<'_>, ValueName<'_>, &str)> = entries
.iter()
.map(|(key, value)| {
let parts = parse_dotted_key(key)?;
let value_name = ValueName::try_from(parts.name).map_err(|source| {
GitError::ConfigInvalidValueName {
name: parts.name.to_owned(),
source,
}
})?;
Ok::<_, GitError>((parts, value_name, *value))
})
.collect::<Result<_, _>>()?;
let config_path = config_path_for_cwd(cwd)?;
let bytes = read_or_empty(&config_path)?;
let mut file = gix::config::File::from_bytes_no_includes(
&bytes,
GixConfigMetadata::api(),
gix_config_init::Options::default(),
)?;
if !mutate(&mut file, &parsed)? {
return Ok(());
}
let extra: usize = entries.iter().map(|(k, v)| k.len() + v.len() + 16).sum();
let mut serialized = Vec::with_capacity(bytes.len() + extra);
file.write_to(&mut serialized).map_err(GitError::Io)?;
write_atomic(&config_path, &serialized)
}
pub fn config_unset(cwd: &Path, key: &str) -> Result<(), GitError> {
let parts = parse_dotted_key(key)?;
let config_path = config_path_for_cwd(cwd)?;
let bytes = read_or_empty(&config_path)?;
let mut file = gix::config::File::from_bytes_no_includes(
&bytes,
GixConfigMetadata::api(),
gix_config_init::Options::default(),
)?;
let subsection = parts.subsection.map(BStr::new);
let Ok(mut section) = file.section_mut(parts.section, subsection) else {
return Err(GitError::ConfigKeyNotSet(key.to_owned()));
};
if section.remove(parts.name).is_none() {
return Err(GitError::ConfigKeyNotSet(key.to_owned()));
}
let mut serialized = Vec::with_capacity(bytes.len());
file.write_to(&mut serialized).map_err(GitError::Io)?;
write_atomic(&config_path, &serialized)
}
pub fn config_unset_if_present(cwd: &Path, key: &str) -> Result<(), GitError> {
match config_unset(cwd, key) {
Ok(()) | Err(GitError::ConfigKeyNotSet(_)) => Ok(()),
Err(e) => Err(e),
}
}
pub fn config_set(cwd: &Path, key: &str, value: &str) -> Result<(), GitError> {
config_set_many(cwd, &[(key, value)])
}
pub fn config_set_many(cwd: &Path, entries: &[(&str, &str)]) -> Result<(), GitError> {
apply_config_entries(cwd, entries, |file, parsed| {
let mut changed = false;
for (parts, value_name, value) in parsed {
let subsection = parts.subsection.map(BStr::new);
let mut section = file
.section_mut_or_create_new(parts.section, subsection)
.map_err(|source| GitError::ConfigInvalidSectionName {
name: parts.section.to_owned(),
source,
})?;
let existing = section.values(parts.name);
if existing.len() == 1 && existing[0].as_ref() == value.as_bytes() {
continue;
}
while section.remove(parts.name).is_some() {}
section.push(value_name.clone(), Some(BStr::new(value)));
changed = true;
}
Ok(changed)
})
}
#[cfg(test)]
mod tests {
use super::*;
use gix::actor::SignatureRef;
use gix::bstr::BStr;
use gix_pack::Find as _;
use std::sync::OnceLock;
use tempfile::TempDir;
fn signature() -> SignatureRef<'static> {
SignatureRef {
name: BStr::new("Test"),
email: BStr::new("test@example.com"),
time: "0 +0000",
}
}
fn empty_repo() -> (Repository, TempDir) {
let dir = TempDir::new().expect("tempdir");
let repo = gix::init(dir.path()).expect("gix::init");
(repo, dir)
}
fn make_marker_tree(repo: &Repository) -> ObjectId {
use gix::objs::tree::{Entry, EntryKind};
let blob_id = repo.write_blob(b"hello\n").expect("write blob").detach();
let tree = gix::objs::Tree {
entries: vec![Entry {
mode: EntryKind::Blob.into(),
filename: "marker".into(),
oid: blob_id,
}],
};
repo.write_object(&tree).expect("write tree").detach()
}
fn add_commit(
repo: &Repository,
ref_name: &str,
parents: &[ObjectId],
message: &str,
) -> ObjectId {
let tree_id = make_marker_tree(repo);
let id = repo
.commit_as(
signature(),
signature(),
ref_name,
message,
tree_id,
parents.iter().copied(),
)
.expect("commit_as");
id.detach()
}
fn commit_with_synthetic_parents(
repo: &Repository,
parents: &[ObjectId],
message: &str,
) -> ObjectId {
let tree_id = make_marker_tree(repo);
let sig = gix::actor::Signature {
name: "Test".into(),
email: "test@example.com".into(),
time: gix::date::Time::default(),
};
let commit = gix::objs::Commit {
tree: tree_id,
parents: parents.iter().copied().collect(),
author: sig.clone(),
committer: sig,
encoding: None,
message: message.into(),
extra_headers: Vec::new(),
};
repo.write_object(&commit).expect("write commit").detach()
}
fn git_available() -> bool {
static AVAIL: OnceLock<bool> = OnceLock::new();
*AVAIL.get_or_init(|| {
std::process::Command::new("git")
.arg("--version")
.output()
.is_ok()
})
}
#[test]
fn sha_from_hex_accepts_valid_lowercase_sha1() {
let s = Sha::from_hex("0123456789abcdef0123456789abcdef01234567").expect("valid");
assert_eq!(s.to_string(), "0123456789abcdef0123456789abcdef01234567");
}
#[test]
fn sha_from_hex_accepts_uppercase_and_normalizes_to_lowercase() {
let s = Sha::from_hex("0123456789ABCDEF0123456789ABCDEF01234567").expect("valid");
assert_eq!(s.to_string(), "0123456789abcdef0123456789abcdef01234567");
}
#[test]
fn sha_from_hex_rejects_wrong_length() {
assert!(Sha::from_hex("abc").is_err());
assert!(Sha::from_hex(&"a".repeat(39)).is_err());
assert!(Sha::from_hex(&"a".repeat(41)).is_err());
}
#[test]
fn sha_from_hex_rejects_non_hex() {
assert!(Sha::from_hex(&"g".repeat(40)).is_err());
assert!(Sha::from_hex("0123456789abcdef0123456789abcdef0123456 ").is_err());
}
#[test]
fn sha_from_hex_rejects_empty() {
assert!(matches!(Sha::from_hex(""), Err(ShaError::Empty)));
}
const INVALID_REF_NAMES: &[&str] = &[
"",
".hidden",
"refs/heads/.hidden",
"refs/heads/foo..bar",
"refs/heads/foo bar",
"refs/heads/",
"refs/heads/main.lock",
"refs/heads/main@{x}",
"refs/heads//main",
"refs/heads/main\x01",
"refs/heads/?bad",
"refs/heads/[bad]",
"refs/heads/^bad",
"refs/heads/~bad",
"refs/heads/*bad",
"refs/heads/:bad",
];
#[test]
fn ref_name_new_accepts_canonical_refs() {
assert!(RefName::new("refs/heads/main").is_ok());
assert!(RefName::new("refs/heads/feature/x").is_ok());
assert!(RefName::new("refs/tags/v1").is_ok());
}
#[test]
fn ref_name_new_rejects_each_invalid_category() {
for name in INVALID_REF_NAMES {
assert!(
RefName::new(*name).is_err(),
"expected RefName::new({name:?}) to fail",
);
}
}
#[test]
fn ref_name_is_valid_matches_new() {
for name in ["refs/heads/main", "refs/heads/feature/x", "refs/tags/v1"] {
assert!(RefName::is_valid(name), "expected is_valid({name:?})");
}
for name in INVALID_REF_NAMES {
assert!(!RefName::is_valid(name), "expected !is_valid({name:?})");
}
}
#[test]
fn is_valid_ref_name_partial_accepts_single_component_head() {
assert!(is_valid_ref_name("HEAD"));
}
#[test]
fn is_valid_ref_name_partial_rejects_each_invalid_category() {
for name in &[
"",
"refs/heads/.hidden",
"refs/heads/foo..bar",
"refs/heads/main.lock",
] {
assert!(!is_valid_ref_name(name), "expected !{name:?}");
}
}
#[test]
fn is_ancestor_self_is_true() {
let (repo, _dir) = empty_repo();
let a = add_commit(&repo, "refs/heads/main", &[], "first");
let sa = Sha::from_object_id(a);
assert!(is_ancestor(&repo, sa, sa).expect("is_ancestor"));
}
#[test]
fn is_ancestor_parent_of_child_is_true() {
let (repo, _dir) = empty_repo();
let a = add_commit(&repo, "refs/heads/main", &[], "a");
let b = add_commit(&repo, "refs/heads/main", &[a], "b");
assert!(
is_ancestor(&repo, Sha::from_object_id(a), Sha::from_object_id(b))
.expect("is_ancestor")
);
}
#[test]
fn is_ancestor_reverse_is_false() {
let (repo, _dir) = empty_repo();
let a = add_commit(&repo, "refs/heads/main", &[], "a");
let b = add_commit(&repo, "refs/heads/main", &[a], "b");
assert!(
!is_ancestor(&repo, Sha::from_object_id(b), Sha::from_object_id(a))
.expect("is_ancestor")
);
}
#[test]
fn is_ancestor_unrelated_is_false() {
let (repo, _dir) = empty_repo();
let a = add_commit(&repo, "refs/heads/main", &[], "a");
let b = add_commit(&repo, "refs/heads/side", &[], "b");
assert!(
!is_ancestor(&repo, Sha::from_object_id(a), Sha::from_object_id(b))
.expect("is_ancestor")
);
}
fn write_annotated_tag(
repo: &Repository,
target: ObjectId,
target_kind: gix::object::Kind,
name: &str,
) -> ObjectId {
let tag = gix::objs::Tag {
target,
target_kind,
name: name.into(),
tagger: Some(signature().to_owned().expect("static signature is valid")),
message: "test".into(),
pgp_signature: None,
};
repo.write_object(&tag).expect("write tag").detach()
}
#[test]
fn peel_lightweight_tag_returns_commit_with_empty_chain() {
let (repo, _dir) = empty_repo();
let commit = add_commit(&repo, "refs/heads/main", &[], "c");
let peeled = peel_tag_chain(&repo, Sha::from_object_id(commit)).expect("peel");
match peeled {
PeeledTip::Commit {
commit: peeled_commit,
tag_chain,
} => {
assert_eq!(peeled_commit.as_object_id(), &commit);
assert!(tag_chain.is_empty());
}
other => panic!("expected Commit variant, got {:?}", variant_name(&other)),
}
}
#[test]
fn peel_annotated_tag_returns_commit_with_one_element_chain() {
let (repo, _dir) = empty_repo();
let commit = add_commit(&repo, "refs/heads/main", &[], "c");
let tag = write_annotated_tag(&repo, commit, gix::object::Kind::Commit, "v1");
let peeled = peel_tag_chain(&repo, Sha::from_object_id(tag)).expect("peel");
match peeled {
PeeledTip::Commit {
commit: peeled_commit,
tag_chain,
} => {
assert_eq!(peeled_commit.as_object_id(), &commit);
assert_eq!(tag_chain, vec![tag]);
}
other => panic!("expected Commit variant, got {:?}", variant_name(&other)),
}
}
#[test]
fn peel_tag_of_tag_returns_commit_with_outer_then_inner_chain() {
let (repo, _dir) = empty_repo();
let commit = add_commit(&repo, "refs/heads/main", &[], "c");
let inner = write_annotated_tag(&repo, commit, gix::object::Kind::Commit, "inner");
let outer = write_annotated_tag(&repo, inner, gix::object::Kind::Tag, "outer");
let peeled = peel_tag_chain(&repo, Sha::from_object_id(outer)).expect("peel");
match peeled {
PeeledTip::Commit {
commit: peeled_commit,
tag_chain,
} => {
assert_eq!(peeled_commit.as_object_id(), &commit);
assert_eq!(tag_chain, vec![outer, inner]);
}
other => panic!("expected Commit variant, got {:?}", variant_name(&other)),
}
}
fn write_tree_with_one_blob(repo: &gix::Repository) -> (ObjectId, ObjectId) {
use gix::objs::tree::{Entry, EntryKind};
let blob = repo.write_blob(b"x").expect("write blob").detach();
let tree = repo
.write_object(&gix::objs::Tree {
entries: vec![Entry {
mode: EntryKind::Blob.into(),
filename: "x".into(),
oid: blob,
}],
})
.expect("write tree")
.detach();
(tree, blob)
}
#[test]
fn peel_tag_pointing_to_tree_returns_tree_variant() {
let (repo, _dir) = empty_repo();
let (tree_id, _blob) = write_tree_with_one_blob(&repo);
let tag = write_annotated_tag(&repo, tree_id, gix::object::Kind::Tree, "tree-tag");
let peeled = peel_tag_chain(&repo, Sha::from_object_id(tag)).expect("peel");
match peeled {
PeeledTip::Tree { tree, tag_chain } => {
assert_eq!(tree, tree_id);
assert_eq!(tag_chain, vec![tag]);
}
other => panic!("expected Tree variant, got {:?}", variant_name(&other)),
}
}
#[test]
fn peel_tag_pointing_to_blob_returns_blob_variant() {
let (repo, _dir) = empty_repo();
let blob_id = repo.write_blob(b"data").expect("write blob").detach();
let tag = write_annotated_tag(&repo, blob_id, gix::object::Kind::Blob, "blob-tag");
let peeled = peel_tag_chain(&repo, Sha::from_object_id(tag)).expect("peel");
match peeled {
PeeledTip::Blob { blob, tag_chain } => {
assert_eq!(blob, blob_id);
assert_eq!(tag_chain, vec![tag]);
}
other => panic!("expected Blob variant, got {:?}", variant_name(&other)),
}
}
#[test]
fn peel_tag_of_tag_of_tree_returns_tree_with_outer_then_inner_chain() {
let (repo, _dir) = empty_repo();
let (tree_id, _blob) = write_tree_with_one_blob(&repo);
let inner = write_annotated_tag(&repo, tree_id, gix::object::Kind::Tree, "inner");
let outer = write_annotated_tag(&repo, inner, gix::object::Kind::Tag, "outer");
let peeled = peel_tag_chain(&repo, Sha::from_object_id(outer)).expect("peel");
match peeled {
PeeledTip::Tree { tree, tag_chain } => {
assert_eq!(tree, tree_id);
assert_eq!(tag_chain, vec![outer, inner]);
}
other => panic!("expected Tree variant, got {:?}", variant_name(&other)),
}
}
#[test]
fn peel_depth_three_tag_chain_to_blob_preserves_chain_order() {
let (repo, _dir) = empty_repo();
let blob_id = repo.write_blob(b"data").expect("write blob").detach();
let inner = write_annotated_tag(&repo, blob_id, gix::object::Kind::Blob, "inner");
let middle = write_annotated_tag(&repo, inner, gix::object::Kind::Tag, "middle");
let outer = write_annotated_tag(&repo, middle, gix::object::Kind::Tag, "outer");
let peeled = peel_tag_chain(&repo, Sha::from_object_id(outer)).expect("peel");
match peeled {
PeeledTip::Blob { blob, tag_chain } => {
assert_eq!(blob, blob_id);
assert_eq!(tag_chain, vec![outer, middle, inner]);
}
other => panic!("expected Blob variant, got {:?}", variant_name(&other)),
}
}
#[test]
fn peel_bare_tree_ref_returns_tree_with_empty_chain() {
let (repo, _dir) = empty_repo();
let (tree_id, _blob) = write_tree_with_one_blob(&repo);
let peeled = peel_tag_chain(&repo, Sha::from_object_id(tree_id)).expect("peel");
match peeled {
PeeledTip::Tree { tree, tag_chain } => {
assert_eq!(tree, tree_id);
assert!(tag_chain.is_empty());
}
other => panic!("expected Tree variant, got {:?}", variant_name(&other)),
}
}
#[test]
fn peel_bare_blob_ref_returns_blob_with_empty_chain() {
let (repo, _dir) = empty_repo();
let blob_id = repo.write_blob(b"data").expect("write blob").detach();
let peeled = peel_tag_chain(&repo, Sha::from_object_id(blob_id)).expect("peel");
match peeled {
PeeledTip::Blob { blob, tag_chain } => {
assert_eq!(blob, blob_id);
assert!(tag_chain.is_empty());
}
other => panic!("expected Blob variant, got {:?}", variant_name(&other)),
}
}
fn variant_name(p: &PeeledTip) -> &'static str {
match p {
PeeledTip::Commit { .. } => "Commit",
PeeledTip::Tree { .. } => "Tree",
PeeledTip::Blob { .. } => "Blob",
}
}
#[test]
fn archive_writes_repo_zip_with_pk_header() {
let (repo, dir) = empty_repo();
add_commit(&repo, "refs/heads/main", &[], "first");
let out_dir = TempDir::new().expect("tempdir");
let zip_path = archive(&repo, out_dir.path(), "refs/heads/main").expect("archive");
assert_eq!(zip_path, out_dir.path().join("repo.zip"));
let bytes = std::fs::read(&zip_path).expect("read zip");
assert_eq!(&bytes[..4], b"PK\x03\x04", "zip local-file-header missing");
drop(dir);
}
#[test]
fn archive_resolves_tag_through_peel() {
let (repo, _dir) = empty_repo();
let commit_oid = add_commit(&repo, "refs/heads/main", &[], "first");
let tag = gix::objs::Tag {
target: commit_oid,
target_kind: gix::object::Kind::Commit,
name: "v1".into(),
tagger: Some(signature().to_owned().expect("static signature is valid")),
message: "release".into(),
pgp_signature: None,
};
let tag_id = repo.write_object(&tag).expect("write tag").detach();
repo.reference(
"refs/tags/v1",
tag_id,
gix::refs::transaction::PreviousValue::MustNotExist,
"create tag",
)
.expect("create tag ref");
let out_dir = TempDir::new().expect("tempdir");
let zip_path = archive(&repo, out_dir.path(), "refs/tags/v1").expect("archive tag");
let bytes = std::fs::read(&zip_path).expect("read zip");
assert_eq!(&bytes[..4], b"PK\x03\x04");
}
#[test]
fn last_commit_message_format_short_sha_then_subject() {
let (repo, _dir) = empty_repo();
add_commit(&repo, "refs/heads/main", &[], "Initial commit");
let msg = last_commit_message(&repo).expect("last_commit_message");
let mut parts = msg.splitn(2, ' ');
let short = parts.next().expect("short");
let subject = parts.next().expect("subject");
assert!(short.len() >= 4, "short id too short: {short:?}");
assert!(short.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(subject, "Initial commit");
}
#[test]
fn last_commit_message_unborn_head_returns_no_commits() {
let (repo, _dir) = empty_repo();
assert!(matches!(
last_commit_message(&repo),
Err(GitError::NoCommits)
));
}
#[test]
fn remote_url_returns_fetch_url() {
let (repo, dir) = empty_repo();
let url = "https://example.com/repo.git";
let config_path = repo.git_dir().join("config");
let existing = std::fs::read_to_string(&config_path).expect("read config");
let amended = format!(
"{existing}\n[remote \"origin\"]\n\turl = {url}\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n"
);
std::fs::write(&config_path, amended).expect("write config");
let repo = gix::open(repo.git_dir()).expect("re-open");
let got = remote_url(&repo, "origin").expect("remote_url");
assert_eq!(got, url);
drop(dir);
}
#[test]
fn remote_url_unknown_remote_returns_remote_not_found() {
let (repo, _dir) = empty_repo();
assert!(matches!(
remote_url(&repo, "missing"),
Err(GitError::RemoteNotFound(_))
));
}
#[test]
fn remote_url_falls_back_to_push_url_when_fetch_url_absent() {
let (repo, dir) = empty_repo();
let push_url = "https://example.com/push.git";
let config_path = repo.git_dir().join("config");
let existing = std::fs::read_to_string(&config_path).expect("read config");
let amended = format!("{existing}\n[remote \"only-push\"]\n\tpushurl = {push_url}\n");
std::fs::write(&config_path, amended).expect("write config");
let repo = gix::open(repo.git_dir()).expect("re-open");
let got = remote_url(&repo, "only-push").expect("remote_url");
assert_eq!(got, push_url);
drop(dir);
}
#[test]
fn parse_dotted_key_two_segments_has_no_subsection() {
let p = parse_dotted_key("lfs.standalonetransferagent").expect("parse");
assert_eq!(p.section, "lfs");
assert_eq!(p.subsection, None);
assert_eq!(p.name, "standalonetransferagent");
}
#[test]
fn parse_dotted_key_three_segments_uses_middle_as_subsection() {
let p = parse_dotted_key("remote.origin.url").expect("parse");
assert_eq!(p.section, "remote");
assert_eq!(p.subsection, Some("origin"));
assert_eq!(p.name, "url");
}
#[test]
fn parse_dotted_key_four_segments_joins_subsection_with_dots() {
let p = parse_dotted_key("lfs.customtransfer.git-lfs-object-store.path").expect("parse");
assert_eq!(p.section, "lfs");
assert_eq!(p.subsection, Some("customtransfer.git-lfs-object-store"));
assert_eq!(p.name, "path");
}
#[test]
fn parse_dotted_key_rejects_invalid_shapes() {
for bad in ["", "nodotsegment", ".name", "section.", "."] {
assert!(
matches!(parse_dotted_key(bad), Err(GitError::ConfigKeyParse(_))),
"expected parse failure for {bad:?}",
);
}
}
#[test]
fn parse_dotted_key_accepts_empty_subsection_for_git_parity() {
let p = parse_dotted_key("a..b").expect("parse");
assert_eq!(p.section, "a");
assert_eq!(p.subsection, Some(""));
assert_eq!(p.name, "b");
}
fn read_local_config(repo: &Repository) -> String {
let path = repo.common_dir().join("config");
std::fs::read_to_string(&path).expect("read config")
}
fn config_values(repo: &Repository, key: &str) -> Vec<String> {
let path = repo.common_dir().join("config");
let bytes = std::fs::read(&path).expect("read config");
let file = gix::config::File::from_bytes_no_includes(
&bytes,
GixConfigMetadata::api(),
gix_config_init::Options::default(),
)
.expect("parse");
file.raw_values(key)
.map(|values| {
values
.into_iter()
.map(|v| v.into_owned().to_string())
.collect()
})
.unwrap_or_default()
}
#[test]
fn config_add_creates_section_and_value() {
let (repo, _dir) = empty_repo();
config_add(
repo.workdir().expect("workdir"),
"lfs.standalonetransferagent",
"git-lfs-object-store",
)
.expect("config_add");
let values = config_values(&repo, "lfs.standalonetransferagent");
assert_eq!(values, vec!["git-lfs-object-store".to_owned()]);
}
#[test]
fn config_add_handles_two_level_subsection() {
let (repo, _dir) = empty_repo();
let key = "lfs.customtransfer.git-lfs-object-store.path";
config_add(
repo.workdir().expect("workdir"),
key,
"git-lfs-object-store",
)
.expect("config_add");
let values = config_values(&repo, key);
assert_eq!(values, vec!["git-lfs-object-store".to_owned()]);
}
#[test]
fn config_add_appends_duplicate_values() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
config_add(cwd, "lfs.standalonetransferagent", "first").expect("first");
config_add(cwd, "lfs.standalonetransferagent", "second").expect("second");
let values = config_values(&repo, "lfs.standalonetransferagent");
assert_eq!(values, vec!["first".to_owned(), "second".to_owned()]);
}
#[test]
fn config_add_preserves_existing_comments() {
let (repo, _dir) = empty_repo();
let path = repo.common_dir().join("config");
let existing = std::fs::read_to_string(&path).expect("read config");
let amended = format!("{existing}# user marker\n[user]\n\tname = Tester\n");
std::fs::write(&path, amended).expect("seed config");
config_add(
repo.workdir().expect("workdir"),
"lfs.standalonetransferagent",
"git-lfs-object-store",
)
.expect("config_add");
let after = read_local_config(&repo);
assert!(
after.contains("# user marker"),
"comment dropped: {after:?}"
);
assert!(
after.contains("name = Tester"),
"user.name dropped: {after:?}"
);
let values = config_values(&repo, "lfs.standalonetransferagent");
assert_eq!(values, vec!["git-lfs-object-store".to_owned()]);
}
#[test]
fn config_add_rejects_invalid_key() {
let (repo, _dir) = empty_repo();
assert!(matches!(
config_add(repo.workdir().expect("workdir"), "", "v"),
Err(GitError::ConfigKeyParse(_))
));
assert!(matches!(
config_add(repo.workdir().expect("workdir"), "nodot", "v"),
Err(GitError::ConfigKeyParse(_))
));
}
#[test]
fn config_add_rejects_invalid_value_name() {
let (repo, _dir) = empty_repo();
let err = config_add(repo.workdir().expect("workdir"), "lfs.123bad", "v")
.expect_err("expected validation error");
assert!(
matches!(err, GitError::ConfigInvalidValueName { .. }),
"got {err:?}"
);
}
#[test]
fn config_add_many_writes_all_entries_in_one_pass() {
let (repo, _dir) = empty_repo();
let entries: &[(&str, &str)] = &[
(
"lfs.customtransfer.git-lfs-object-store.path",
"git-lfs-object-store",
),
("lfs.standalonetransferagent", "git-lfs-object-store"),
];
config_add_many(repo.workdir().expect("workdir"), entries).expect("config_add_many");
for (key, value) in entries {
assert_eq!(config_values(&repo, key), vec![(*value).to_owned()]);
}
}
#[test]
fn config_add_many_validates_all_entries_before_writing() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
let path_before = read_local_config(&repo);
let err = config_add_many(
cwd,
&[
("lfs.standalonetransferagent", "git-lfs-object-store"),
("nodot", "v"),
],
)
.expect_err("expected parse failure on second entry");
assert!(matches!(err, GitError::ConfigKeyParse(_)), "got {err:?}");
assert_eq!(read_local_config(&repo), path_before);
assert!(
config_values(&repo, "lfs.standalonetransferagent").is_empty(),
"first entry should not have been written",
);
}
#[test]
fn config_add_many_empty_input_is_noop() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
let before = read_local_config(&repo);
config_add_many(cwd, &[]).expect("noop");
assert_eq!(read_local_config(&repo), before);
}
#[test]
fn config_unset_removes_existing_value() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
config_add(cwd, "lfs.customtransfer.git-lfs-object-store.args", "debug").expect("seed");
config_unset(cwd, "lfs.customtransfer.git-lfs-object-store.args").expect("unset");
let values = config_values(&repo, "lfs.customtransfer.git-lfs-object-store.args");
assert!(values.is_empty(), "value still present: {values:?}");
}
#[test]
fn config_unset_missing_key_returns_typed_error() {
let (repo, _dir) = empty_repo();
let err = config_unset(repo.workdir().expect("workdir"), "lfs.never.set")
.expect_err("expected error");
assert!(matches!(err, GitError::ConfigKeyNotSet(ref k) if k == "lfs.never.set"));
}
#[test]
fn config_unset_missing_section_returns_typed_error() {
let (repo, _dir) = empty_repo();
let err = config_unset(repo.workdir().expect("workdir"), "ghost.value")
.expect_err("expected error");
assert!(matches!(err, GitError::ConfigKeyNotSet(_)), "got {err:?}");
}
#[test]
fn config_unset_missing_key_within_existing_section_returns_typed_error() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
config_add(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("seed");
let err = config_unset(cwd, "lfs.othervalue").expect_err("expected error");
assert!(
matches!(err, GitError::ConfigKeyNotSet(ref k) if k == "lfs.othervalue"),
"got {err:?}"
);
}
#[test]
fn config_add_then_native_git_can_read_value() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
config_add(
cwd,
"lfs.customtransfer.git-lfs-object-store.path",
"git-lfs-object-store",
)
.expect("config_add");
let output = std::process::Command::new("git")
.args([
"config",
"--get",
"lfs.customtransfer.git-lfs-object-store.path",
])
.current_dir(cwd)
.output()
.expect("git config --get");
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
assert_eq!(stdout.trim(), "git-lfs-object-store");
}
#[test]
fn config_set_writes_value_when_key_absent() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("config_set");
assert_eq!(
config_values(&repo, "lfs.standalonetransferagent"),
vec!["git-lfs-object-store".to_owned()],
);
}
#[test]
fn config_set_is_idempotent_on_matching_value() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("first");
config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("second");
assert_eq!(
config_values(&repo, "lfs.standalonetransferagent"),
vec!["git-lfs-object-store".to_owned()],
);
}
#[test]
fn config_set_replaces_differing_value() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
config_set(cwd, "lfs.standalonetransferagent", "old-name").expect("first");
config_set(cwd, "lfs.standalonetransferagent", "new-name").expect("second");
assert_eq!(
config_values(&repo, "lfs.standalonetransferagent"),
vec!["new-name".to_owned()],
);
}
#[test]
fn config_set_collapses_legacy_duplicates() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
config_add(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("seed 1");
config_add(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("seed 2");
assert_eq!(
config_values(&repo, "lfs.standalonetransferagent").len(),
2,
"pre-condition: two duplicate entries",
);
config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("set");
assert_eq!(
config_values(&repo, "lfs.standalonetransferagent"),
vec!["git-lfs-object-store".to_owned()],
);
}
#[test]
fn config_set_idempotent_call_does_not_rewrite_file() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("first");
let after_first = read_local_config(&repo);
config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("second");
assert_eq!(read_local_config(&repo), after_first);
}
#[test]
fn config_set_many_writes_both_entries() {
let (repo, _dir) = empty_repo();
let entries: &[(&str, &str)] = &[
(
"lfs.customtransfer.git-lfs-object-store.path",
"git-lfs-object-store",
),
("lfs.standalonetransferagent", "git-lfs-object-store"),
];
config_set_many(repo.workdir().expect("workdir"), entries).expect("config_set_many");
for (key, value) in entries {
assert_eq!(config_values(&repo, key), vec![(*value).to_owned()]);
}
}
#[test]
fn config_set_many_is_idempotent_across_all_entries() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
let entries: &[(&str, &str)] = &[
(
"lfs.customtransfer.git-lfs-object-store.path",
"git-lfs-object-store",
),
("lfs.standalonetransferagent", "git-lfs-object-store"),
];
config_set_many(cwd, entries).expect("first");
config_set_many(cwd, entries).expect("second");
for (key, value) in entries {
assert_eq!(
config_values(&repo, key),
vec![(*value).to_owned()],
"key {key:?} should have a single entry after two set_many calls",
);
}
}
#[test]
fn config_set_many_validates_all_entries_before_writing() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
let before = read_local_config(&repo);
let err = config_set_many(
cwd,
&[
("lfs.standalonetransferagent", "git-lfs-object-store"),
("nodot", "v"),
],
)
.expect_err("expected parse failure on second entry");
assert!(matches!(err, GitError::ConfigKeyParse(_)), "got {err:?}");
assert_eq!(read_local_config(&repo), before);
assert!(
config_values(&repo, "lfs.standalonetransferagent").is_empty(),
"first entry should not have been written",
);
}
#[test]
fn config_set_many_empty_input_is_noop() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
let before = read_local_config(&repo);
config_set_many(cwd, &[]).expect("noop");
assert_eq!(read_local_config(&repo), before);
}
#[test]
fn config_unset_if_present_removes_existing_value() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
config_add(cwd, "lfs.customtransfer.git-lfs-object-store.args", "debug").expect("seed");
config_unset_if_present(cwd, "lfs.customtransfer.git-lfs-object-store.args")
.expect("unset");
assert!(config_values(&repo, "lfs.customtransfer.git-lfs-object-store.args").is_empty(),);
}
#[test]
fn config_unset_if_present_succeeds_when_key_absent() {
let (repo, _dir) = empty_repo();
let cwd = repo.workdir().expect("workdir");
config_unset_if_present(cwd, "lfs.never.set").expect("missing section is ok");
config_add(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("seed");
config_unset_if_present(cwd, "lfs.othervalue").expect("missing value is ok");
assert_eq!(
config_values(&repo, "lfs.standalonetransferagent"),
vec!["git-lfs-object-store".to_owned()],
);
}
#[test]
fn config_unset_if_present_propagates_non_keynotset_errors() {
let (repo, _dir) = empty_repo();
let err = config_unset_if_present(repo.workdir().expect("workdir"), "")
.expect_err("expected parse error");
assert!(matches!(err, GitError::ConfigKeyParse(_)), "got {err:?}");
}
#[test]
fn shallow_boundaries_depth_one_returns_tip() {
let (repo, _dir) = empty_repo();
let a = add_commit(&repo, "refs/heads/main", &[], "a");
let b = add_commit(&repo, "refs/heads/main", &[a], "b");
let tip = Sha::from_object_id(b);
let bounds =
shallow_boundaries(&repo, tip, NonZeroU32::new(1).unwrap()).expect("boundaries");
assert_eq!(bounds, vec![b]);
}
#[test]
fn shallow_boundaries_returns_empty_when_history_shorter_than_depth() {
let (repo, _dir) = empty_repo();
let a = add_commit(&repo, "refs/heads/main", &[], "a");
let tip = Sha::from_object_id(a);
let bounds =
shallow_boundaries(&repo, tip, NonZeroU32::new(5).unwrap()).expect("boundaries");
assert!(bounds.is_empty(), "expected empty, got {bounds:?}");
}
#[test]
fn shallow_boundaries_at_merge_returns_frontier_at_depth() {
let (repo, _dir) = empty_repo();
let c = add_commit(&repo, "refs/heads/main", &[], "C");
let a = add_commit(&repo, "refs/heads/main", &[c], "A");
let b = add_commit(&repo, "refs/heads/side", &[c], "B");
let m = add_commit(&repo, "refs/heads/main", &[a, b], "M");
let tip = Sha::from_object_id(m);
let bounds =
shallow_boundaries(&repo, tip, NonZeroU32::new(2).unwrap()).expect("boundaries");
let mut sorted = bounds.clone();
sorted.sort_unstable();
let mut expected = vec![a, b];
expected.sort_unstable();
assert_eq!(sorted, expected);
}
#[test]
fn shallow_boundaries_at_merge_with_depth_one_returns_tip() {
let (repo, _dir) = empty_repo();
let a = add_commit(&repo, "refs/heads/main", &[], "A");
let b = add_commit(&repo, "refs/heads/side", &[], "B");
let m = add_commit(&repo, "refs/heads/main", &[a, b], "M");
let tip = Sha::from_object_id(m);
let bounds =
shallow_boundaries(&repo, tip, NonZeroU32::new(1).unwrap()).expect("boundaries");
assert_eq!(bounds, vec![m]);
}
#[test]
fn write_shallow_file_writes_boundaries_when_absent() {
let (repo, dir) = empty_repo();
let a = add_commit(&repo, "refs/heads/main", &[], "a");
write_shallow_file(dir.path(), &[a]).expect("write");
let path = repo.git_dir().join("shallow");
let contents = std::fs::read_to_string(&path).expect("read shallow");
assert_eq!(contents, format!("{a}\n"));
}
#[test]
fn write_shallow_file_dedupes_entries() {
let (repo, dir) = empty_repo();
let a = add_commit(&repo, "refs/heads/main", &[], "a");
let path = repo.git_dir().join("shallow");
std::fs::write(&path, format!("{a}\n")).expect("seed");
write_shallow_file(dir.path(), &[a]).expect("write");
let contents = std::fs::read_to_string(&path).expect("read");
assert_eq!(contents, format!("{a}\n"));
}
#[test]
fn write_shallow_file_no_boundaries_no_existing_does_not_create_file() {
let (repo, dir) = empty_repo();
let path = repo.git_dir().join("shallow");
write_shallow_file(dir.path(), &[]).expect("noop");
assert!(!path.exists(), "shallow file unexpectedly created");
}
#[test]
fn write_shallow_file_prunes_existing_when_parents_in_odb() {
let (repo, dir) = empty_repo();
let a = add_commit(&repo, "refs/heads/main", &[], "a");
let b = add_commit(&repo, "refs/heads/main", &[a], "b");
let path = repo.git_dir().join("shallow");
std::fs::write(&path, format!("{b}\n")).expect("seed depth-1 tip");
write_shallow_file(dir.path(), &[a]).expect("deepen");
let contents = std::fs::read_to_string(&path).expect("read");
assert_eq!(contents, format!("{a}\n"));
}
#[test]
fn write_shallow_file_unlinks_when_set_becomes_empty_after_pruning() {
let (repo, dir) = empty_repo();
let a = add_commit(&repo, "refs/heads/main", &[], "a");
let b = add_commit(&repo, "refs/heads/main", &[a], "b");
let path = repo.git_dir().join("shallow");
std::fs::write(&path, format!("{b}\n")).expect("seed");
write_shallow_file(dir.path(), &[]).expect("deepen-to-full");
assert!(!path.exists(), "shallow file should be unlinked");
}
#[test]
fn write_shallow_file_drops_existing_root_commit() {
let (repo, dir) = empty_repo();
let a = add_commit(&repo, "refs/heads/main", &[], "a");
let b = add_commit(&repo, "refs/heads/main", &[a], "b");
let path = repo.git_dir().join("shallow");
std::fs::write(&path, format!("{a}\n")).expect("seed");
write_shallow_file(dir.path(), &[b]).expect("write");
let contents = std::fs::read_to_string(&path).expect("read");
assert_eq!(contents, format!("{b}\n"));
}
#[test]
fn write_shallow_file_unlinks_when_only_existing_was_root() {
let (repo, dir) = empty_repo();
let a = add_commit(&repo, "refs/heads/main", &[], "a");
let path = repo.git_dir().join("shallow");
std::fs::write(&path, format!("{a}\n")).expect("seed");
write_shallow_file(dir.path(), &[]).expect("write");
assert!(!path.exists(), "shallow file should be unlinked");
}
#[test]
fn write_shallow_file_keeps_existing_when_a_parent_is_missing() {
let (repo, dir) = empty_repo();
let synthetic_parent =
ObjectId::from_hex(b"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").expect("synthetic OID");
let orphan = commit_with_synthetic_parents(&repo, &[synthetic_parent], "orphan");
let new_root = add_commit(&repo, "refs/heads/main", &[], "new_root");
let path = repo.git_dir().join("shallow");
std::fs::write(&path, format!("{orphan}\n")).expect("seed");
write_shallow_file(dir.path(), &[new_root]).expect("write");
let contents = std::fs::read_to_string(&path).expect("read");
let mut expected = [format!("{orphan}"), format!("{new_root}")];
expected.sort();
assert_eq!(contents.trim(), expected.join("\n"));
}
#[test]
fn write_shallow_file_keeps_octopus_merge_when_any_parent_missing() {
let (repo, dir) = empty_repo();
let p1 = add_commit(&repo, "refs/heads/p1", &[], "p1");
let p2 = add_commit(&repo, "refs/heads/p2", &[], "p2");
let synthetic =
ObjectId::from_hex(b"cafef00dcafef00dcafef00dcafef00dcafef00d").expect("synthetic");
let merge = commit_with_synthetic_parents(&repo, &[p1, p2, synthetic], "octopus");
let path = repo.git_dir().join("shallow");
std::fs::write(&path, format!("{merge}\n")).expect("seed");
write_shallow_file(dir.path(), &[]).expect("write");
let contents = std::fs::read_to_string(&path).expect("read");
assert_eq!(contents, format!("{merge}\n"));
}
#[test]
fn write_shallow_file_drops_entry_pointing_at_non_commit() {
let (repo, dir) = empty_repo();
let tree_id = make_marker_tree(&repo);
let path = repo.git_dir().join("shallow");
std::fs::write(&path, format!("{tree_id}\n")).expect("seed");
write_shallow_file(dir.path(), &[]).expect("write");
assert!(!path.exists(), "stale tree entry should not preserve file");
}
#[test]
fn write_shallow_file_drops_entry_missing_from_odb() {
let (repo, dir) = empty_repo();
let synthetic =
ObjectId::from_hex(b"abcdef0123456789abcdef0123456789abcdef01").expect("synthetic");
let path = repo.git_dir().join("shallow");
std::fs::write(&path, format!("{synthetic}\n")).expect("seed");
write_shallow_file(dir.path(), &[]).expect("write");
assert!(!path.exists(), "missing-OID entry should not preserve file");
}
#[tokio::test]
async fn bundle_unbundle_round_trips_natively() {
let (src_repo, src_dir) = empty_repo();
let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
let sha = Sha::from_object_id(oid);
let ref_name = RefName::new("refs/heads/main").expect("RefName");
let bundles = TempDir::new().expect("tempdir");
let bundle_path = bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
.await
.expect("bundle");
assert!(bundle_path.exists(), "bundle not written");
let first_line = {
use std::io::BufRead as _;
let f = std::fs::File::open(&bundle_path).expect("open bundle");
let mut buf = String::new();
std::io::BufReader::new(f)
.read_line(&mut buf)
.expect("read");
buf.trim_end().to_owned()
};
assert_eq!(first_line, "# v2 git bundle", "bundle magic mismatch");
let (dst_repo, _dst_dir) = empty_repo();
unbundle(&dst_repo, bundles.path(), sha)
.await
.expect("unbundle");
assert!(
dst_repo
.objects
.clone()
.into_inner()
.contains(sha.as_object_id()),
"commit object not in dst ODB after unbundle"
);
branch::resolve(&dst_repo, &sha.to_string()).expect("resolve must work on bundled OID");
let pack_dir = dst_repo.git_dir().join("objects/pack");
let keep_files: Vec<_> = std::fs::read_dir(&pack_dir)
.expect("read pack dir")
.filter_map(Result::ok)
.filter(|e| e.path().extension().is_some_and(|x| x == "keep"))
.collect();
assert!(
keep_files.is_empty(),
".keep files not removed after unbundle: {keep_files:?}"
);
drop(src_dir);
}
#[tokio::test]
async fn bundle_includes_full_commit_history() {
let (src_repo, src_dir) = empty_repo();
let oid1 = add_commit(&src_repo, "refs/heads/main", &[], "first");
let oid2 = add_commit(&src_repo, "refs/heads/main", &[oid1], "second");
let sha = Sha::from_object_id(oid2);
let ref_name = RefName::new("refs/heads/main").expect("RefName");
let bundles = TempDir::new().expect("tempdir");
bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
.await
.expect("bundle");
let (dst_repo, _dst_dir) = empty_repo();
unbundle(&dst_repo, bundles.path(), sha)
.await
.expect("unbundle");
let dst_odb = dst_repo.objects.clone().into_inner();
assert!(
dst_odb.contains(&oid1),
"ancestor commit not in dst ODB after unbundle"
);
assert!(
dst_odb.contains(&oid2),
"tip commit not in dst ODB after unbundle"
);
let blob_id = src_repo.write_blob(b"hello\n").expect("blob id").detach();
assert!(
dst_odb.contains(&blob_id),
"blob object not in dst ODB — ObjectExpansion::TreeContents may not be working"
);
drop(src_dir);
}
#[tokio::test]
async fn unbundle_is_idempotent_on_duplicate_install() {
let (src_repo, src_dir) = empty_repo();
let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
let sha = Sha::from_object_id(oid);
let ref_name = RefName::new("refs/heads/main").expect("RefName");
let bundles = TempDir::new().expect("tempdir");
bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
.await
.expect("bundle");
let (dst_repo, _dst_dir) = empty_repo();
unbundle(&dst_repo, bundles.path(), sha)
.await
.expect("first unbundle");
unbundle(&dst_repo, bundles.path(), sha)
.await
.expect("second unbundle (duplicate install)");
let pack_dir = dst_repo.git_dir().join("objects/pack");
let keep_files: Vec<_> = std::fs::read_dir(&pack_dir)
.expect("read pack dir")
.filter_map(Result::ok)
.filter(|e| e.path().extension().is_some_and(|x| x == "keep"))
.collect();
assert!(
keep_files.is_empty(),
".keep files after duplicate unbundle: {keep_files:?}"
);
assert!(
dst_repo.objects.clone().into_inner().contains(&oid),
"commit not in dst ODB after duplicate unbundle"
);
drop(src_dir);
}
#[tokio::test]
async fn concurrent_unbundle_same_sha_is_idempotent() {
let (src_repo, src_dir) = empty_repo();
let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
let sha = Sha::from_object_id(oid);
let ref_name = RefName::new("refs/heads/main").expect("RefName");
let bundles = TempDir::new().expect("tempdir");
bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
.await
.expect("bundle");
let (dst_repo, _dst_dir) = empty_repo();
let dst_cwd = repo_cwd(&dst_repo).to_owned();
let bundles_path = bundles.path().to_owned();
let (r1, r2) = tokio::join!(
unbundle_at(&dst_cwd, &bundles_path, sha),
unbundle_at(&dst_cwd, &bundles_path, sha),
);
assert!(r1.is_ok(), "first concurrent unbundle failed: {r1:?}");
assert!(r2.is_ok(), "second concurrent unbundle failed: {r2:?}");
let pack_dir = dst_repo.git_dir().join("objects/pack");
let keep_files: Vec<_> = std::fs::read_dir(&pack_dir)
.expect("read pack dir")
.filter_map(Result::ok)
.filter(|e| e.path().extension().is_some_and(|x| x == "keep"))
.collect();
assert!(
keep_files.is_empty(),
".keep files lingered after concurrent unbundle: {keep_files:?}"
);
assert!(
dst_repo.objects.clone().into_inner().contains(&oid),
"commit not in dst ODB after concurrent unbundle"
);
drop(src_dir);
}
#[tokio::test]
async fn git_bundle_create_readable_by_native_unbundle() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (src_repo, src_dir) = empty_repo();
let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
let sha = Sha::from_object_id(oid);
let bundles = TempDir::new().expect("tempdir");
let bundle_path = bundles.path().join(format!("{sha}.bundle"));
let output = std::process::Command::new("git")
.args(["bundle", "create"])
.arg(&bundle_path)
.arg("refs/heads/main")
.current_dir(src_dir.path())
.output()
.expect("git bundle create");
assert!(
output.status.success(),
"git bundle create failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let (dst_repo, _dst_dir) = empty_repo();
unbundle(&dst_repo, bundles.path(), sha)
.await
.expect("native unbundle of git-created bundle");
assert!(
dst_repo.objects.clone().into_inner().contains(&oid),
"commit not in dst ODB after native unbundle of git-created bundle"
);
drop(src_dir);
}
#[tokio::test]
async fn native_bundle_create_accepted_by_git() {
if !git_available() {
eprintln!("skipping: git not on PATH");
return;
}
let (src_repo, src_dir) = empty_repo();
let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
let sha = Sha::from_object_id(oid);
let ref_name = RefName::new("refs/heads/main").expect("RefName");
let bundles = TempDir::new().expect("tempdir");
let bundle_path = bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
.await
.expect("native bundle");
drop(src_repo);
let output = std::process::Command::new("git")
.args(["bundle", "verify"])
.arg(&bundle_path)
.current_dir(src_dir.path())
.output()
.expect("git bundle verify");
assert!(
output.status.success(),
"git bundle verify rejected our bundle:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let (dst_repo, dst_dir) = empty_repo();
let output = std::process::Command::new("git")
.args(["bundle", "unbundle"])
.arg(&bundle_path)
.current_dir(dst_dir.path())
.output()
.expect("git bundle unbundle");
assert!(
output.status.success(),
"git bundle unbundle failed on native bundle:\n{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
dst_repo.objects.clone().into_inner().contains(&oid),
"commit not in dst ODB after git bundle unbundle of native bundle"
);
drop(src_dir);
}
}