1use std::collections::{HashSet, VecDeque};
12use std::fmt;
13use std::io;
14use std::io::Write as _;
15use std::num::NonZeroU32;
16use std::path::{Path, PathBuf};
17use std::string::FromUtf8Error;
18use std::sync::atomic::AtomicBool;
19
20pub(crate) mod branch;
21
22use gix::Repository;
23use gix::bstr::{BStr, ByteSlice};
24use gix::config::file::Metadata as GixConfigMetadata;
25use gix::config::file::init as gix_config_init;
26use gix::config::parse::section::{
27 ValueName, header as gix_section_header, value_name as gix_value_name,
28};
29use gix::lock as gix_lock;
30use gix::progress::Discard;
31use gix::remote::Direction;
32use gix_hash::ObjectId;
33use thiserror::Error;
34use tracing::debug;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub struct Sha(ObjectId);
46
47impl Sha {
48 pub fn from_hex(hex: &str) -> Result<Self, ShaError> {
58 if hex.is_empty() {
59 return Err(ShaError::Empty);
60 }
61 Ok(Sha(ObjectId::from_hex(hex.as_bytes())?))
62 }
63
64 #[must_use]
66 pub fn from_object_id(id: ObjectId) -> Self {
67 Sha(id)
68 }
69
70 #[must_use]
72 pub fn as_object_id(&self) -> &ObjectId {
73 &self.0
74 }
75}
76
77impl fmt::Display for Sha {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 self.0.fmt(f)
80 }
81}
82
83#[derive(Debug, Error)]
85pub enum ShaError {
86 #[error("expected hex digits, got empty string")]
88 Empty,
89 #[error(transparent)]
91 Decode(#[from] gix_hash::decode::Error),
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Hash)]
97pub struct RefName(String);
98
99impl RefName {
100 pub fn new(name: impl Into<String>) -> Result<Self, RefNameError> {
107 let name = name.into();
108 match gix_validate::reference::name(BStr::new(&name)) {
109 Ok(_) => Ok(RefName(name)),
110 Err(source) => Err(RefNameError::Invalid { name, source }),
111 }
112 }
113
114 #[must_use]
116 pub fn as_str(&self) -> &str {
117 &self.0
118 }
119
120 #[must_use]
126 pub fn is_valid(name: &str) -> bool {
127 gix_validate::reference::name(BStr::new(name)).is_ok()
128 }
129}
130
131impl fmt::Display for RefName {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 f.write_str(&self.0)
134 }
135}
136
137impl AsRef<str> for RefName {
138 fn as_ref(&self) -> &str {
139 &self.0
140 }
141}
142
143impl From<RefName> for String {
144 fn from(value: RefName) -> Self {
145 value.0
146 }
147}
148
149#[derive(Debug, Error)]
151pub enum RefNameError {
152 #[error("invalid ref name {name:?}: {source}")]
154 Invalid {
155 name: String,
157 #[source]
159 source: gix_validate::reference::name::Error,
160 },
161}
162
163#[must_use]
170pub fn is_valid_ref_name(name: &str) -> bool {
171 gix_validate::reference::name_partial(BStr::new(name)).is_ok()
172}
173
174#[derive(Debug, Error)]
176pub enum GitError {
177 #[error("rev-spec is empty")]
179 EmptySpec,
180 #[error("repository has no commits")]
182 NoCommits,
183 #[error("remote not found: {0}")]
185 RemoteNotFound(String),
186 #[error("remote has no fetch or push URL: {0}")]
188 RemoteHasNoUrl(String),
189 #[error("remote {remote} URL is not valid UTF-8")]
191 NonUtf8RemoteUrl {
192 remote: String,
194 #[source]
196 source: FromUtf8Error,
197 },
198 #[error("bundle: {0}")]
200 Bundle(Box<crate::bundle::BundleError>),
201 #[error("blocking task panicked")]
203 Panic(#[from] tokio::task::JoinError),
204 #[error(transparent)]
206 Io(#[from] io::Error),
207 #[error(transparent)]
209 RevParse(#[from] gix::revision::spec::parse::single::Error),
210 #[error(transparent)]
212 FindObject(#[from] gix::object::find::existing::Error),
213 #[error(transparent)]
215 PeelToKind(#[from] gix::object::peel::to_kind::Error),
216 #[error(transparent)]
218 HeadCommit(#[from] gix::reference::head_commit::Error),
219 #[error(transparent)]
221 DecodeCommit(#[from] gix::objs::decode::Error),
222 #[error(transparent)]
224 ShortId(#[from] gix::id::shorten::Error),
225 #[error(transparent)]
227 MergeBase(Box<gix::repository::merge_base::Error>),
228 #[error(transparent)]
230 WorktreeStream(Box<gix::repository::worktree_stream::Error>),
231 #[error(transparent)]
233 WorktreeArchive(Box<gix::repository::worktree_archive::Error>),
234 #[error(transparent)]
236 FindRemote(Box<gix::remote::find::existing::Error>),
237 #[error(transparent)]
239 Open(Box<gix::open::Error>),
240 #[error(transparent)]
242 Discover(Box<gix::discover::Error>),
243 #[error("invalid config key {0:?}: must be of the form <section>[.<subsection>].<name>")]
245 ConfigKeyParse(String),
246 #[error("invalid config section name {name:?}: {source}")]
248 ConfigInvalidSectionName {
249 name: String,
251 #[source]
253 source: gix_section_header::Error,
254 },
255 #[error("invalid config value name {name:?}: {source}")]
257 ConfigInvalidValueName {
258 name: String,
260 #[source]
262 source: gix_value_name::Error,
263 },
264 #[error("config key not set: {0}")]
266 ConfigKeyNotSet(String),
267 #[error(transparent)]
269 ConfigParse(Box<gix_config_init::Error>),
270 #[error(transparent)]
273 ConfigLock(Box<gix_lock::acquire::Error>),
274 #[error("tag chain contains a cycle at {oid}")]
280 TagChainCycle {
281 oid: ObjectId,
283 },
284}
285
286impl From<gix::open::Error> for GitError {
287 fn from(e: gix::open::Error) -> Self {
288 GitError::Open(Box::new(e))
289 }
290}
291
292impl From<gix::repository::merge_base::Error> for GitError {
293 fn from(e: gix::repository::merge_base::Error) -> Self {
294 GitError::MergeBase(Box::new(e))
295 }
296}
297
298impl From<gix::repository::worktree_stream::Error> for GitError {
299 fn from(e: gix::repository::worktree_stream::Error) -> Self {
300 GitError::WorktreeStream(Box::new(e))
301 }
302}
303
304impl From<gix::repository::worktree_archive::Error> for GitError {
305 fn from(e: gix::repository::worktree_archive::Error) -> Self {
306 GitError::WorktreeArchive(Box::new(e))
307 }
308}
309
310impl From<gix::remote::find::existing::Error> for GitError {
311 fn from(e: gix::remote::find::existing::Error) -> Self {
312 GitError::FindRemote(Box::new(e))
313 }
314}
315
316impl From<gix::discover::Error> for GitError {
317 fn from(e: gix::discover::Error) -> Self {
318 GitError::Discover(Box::new(e))
319 }
320}
321
322impl From<gix_config_init::Error> for GitError {
323 fn from(e: gix_config_init::Error) -> Self {
324 GitError::ConfigParse(Box::new(e))
325 }
326}
327
328impl From<gix_lock::acquire::Error> for GitError {
329 fn from(e: gix_lock::acquire::Error) -> Self {
330 GitError::ConfigLock(Box::new(e))
331 }
332}
333
334fn repo_cwd(repo: &Repository) -> &Path {
337 repo.workdir().unwrap_or_else(|| repo.git_dir())
338}
339
340pub async fn bundle(
358 repo: &Repository,
359 folder: &Path,
360 sha: Sha,
361 spec: &str,
362) -> Result<PathBuf, GitError> {
363 let cwd = repo_cwd(repo).to_owned();
366 bundle_at(&cwd, folder, sha, spec).await
367}
368
369pub async fn bundle_at(
380 cwd: &Path,
381 folder: &Path,
382 sha: Sha,
383 spec: &str,
384) -> Result<PathBuf, GitError> {
385 let (cwd, folder, spec) = (cwd.to_owned(), folder.to_owned(), spec.to_owned());
386 tokio::task::spawn_blocking(move || crate::bundle::create(&cwd, &folder, sha, &spec))
387 .await?
388 .map_err(|e| GitError::Bundle(Box::new(e)))
389}
390
391pub async fn unbundle(repo: &Repository, folder: &Path, sha: Sha) -> Result<(), GitError> {
402 unbundle_at(repo_cwd(repo), folder, sha).await
403}
404
405pub async fn unbundle_at(cwd: &Path, folder: &Path, sha: Sha) -> Result<(), GitError> {
416 let (cwd, folder) = (cwd.to_owned(), folder.to_owned());
417 tokio::task::spawn_blocking(move || crate::bundle::unbundle(&cwd, &folder, sha))
418 .await?
419 .map_err(|e| GitError::Bundle(Box::new(e)))
420}
421
422pub fn is_ancestor(repo: &Repository, ancestor: Sha, descendant: Sha) -> Result<bool, GitError> {
433 if ancestor == descendant {
434 return Ok(true);
435 }
436 let ancestor_oid = *ancestor.as_object_id();
437 let descendant_oid = *descendant.as_object_id();
438 match repo.merge_base(ancestor_oid, descendant_oid) {
439 Ok(base) => Ok(base.detach() == ancestor_oid),
440 Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(false),
441 Err(e) => Err(e.into()),
442 }
443}
444
445pub(crate) enum PeeledTip {
458 Commit {
461 commit: Sha,
462 tag_chain: Vec<ObjectId>,
463 },
464 Tree {
468 tree: ObjectId,
469 tag_chain: Vec<ObjectId>,
470 },
471 Blob {
475 blob: ObjectId,
476 tag_chain: Vec<ObjectId>,
477 },
478}
479
480pub(crate) fn peel_tag_chain(repo: &Repository, tip: Sha) -> Result<PeeledTip, GitError> {
502 let mut visited: HashSet<ObjectId> = HashSet::new();
507 let mut tag_chain = Vec::new();
508 let mut current = *tip.as_object_id();
509 loop {
510 if !visited.insert(current) {
511 return Err(GitError::TagChainCycle { oid: current });
512 }
513 let object = repo.find_object(current)?;
514 match object.kind {
515 gix::object::Kind::Commit => {
516 return Ok(PeeledTip::Commit {
517 commit: Sha::from_object_id(current),
518 tag_chain,
519 });
520 }
521 gix::object::Kind::Tag => {
522 tag_chain.push(current);
523 current = object.into_tag().target_id()?.detach();
524 }
525 gix::object::Kind::Tree => {
526 return Ok(PeeledTip::Tree {
527 tree: current,
528 tag_chain,
529 });
530 }
531 gix::object::Kind::Blob => {
532 return Ok(PeeledTip::Blob {
533 blob: current,
534 tag_chain,
535 });
536 }
537 }
538 }
539}
540
541pub(crate) fn shallow_boundaries(
572 repo: &Repository,
573 tip: Sha,
574 max_depth: NonZeroU32,
575) -> Result<Vec<ObjectId>, GitError> {
576 let max_depth = max_depth.get();
577 let tip_oid = match peel_tag_chain(repo, tip)? {
581 PeeledTip::Commit { commit, .. } => *commit.as_object_id(),
582 PeeledTip::Tree { .. } | PeeledTip::Blob { .. } => {
583 tracing::warn!(
584 tip = %tip,
585 "shallow fetch (--depth=N) is not meaningful for non-commit-tipped refs; \
586 falling back to full fetch (no .git/shallow markers written)",
587 );
588 return Ok(Vec::new());
589 }
590 };
591
592 let mut seen: HashSet<ObjectId> = HashSet::new();
595 let mut frontier: Vec<ObjectId> = Vec::new();
596 let mut queue: VecDeque<(ObjectId, u32)> = VecDeque::new();
597 queue.push_back((tip_oid, 1));
598
599 while let Some((oid, depth)) = queue.pop_front() {
600 if !seen.insert(oid) {
601 continue;
602 }
603 if depth == max_depth {
604 frontier.push(oid);
607 continue;
608 }
609 let commit = repo
610 .find_object(oid)?
611 .peel_to_kind(gix::object::Kind::Commit)?;
612 let commit = commit.into_commit();
613 for parent in commit.parent_ids() {
614 let parent_oid = parent.detach();
615 if !seen.contains(&parent_oid) {
616 queue.push_back((parent_oid, depth + 1));
617 }
618 }
619 }
620
621 Ok(frontier)
622}
623
624const SHA1_HEX_LINE_LEN: usize = 41;
626
627pub(crate) fn write_shallow_file(repo_dir: &Path, boundaries: &[ObjectId]) -> Result<(), GitError> {
665 let path = git_dir_for(repo_dir).join("shallow");
666
667 let mut existing: HashSet<ObjectId> = HashSet::new();
671 for line in read_or_empty(&path)?.split(|&b| b == b'\n') {
672 let line = line.trim_ascii();
673 if !line.is_empty()
674 && let Ok(oid) = ObjectId::from_hex(line)
675 {
676 existing.insert(oid);
677 }
678 }
679
680 let mut final_set: HashSet<ObjectId> = boundaries.iter().copied().collect();
685 existing.retain(|oid| !final_set.contains(oid));
686 let stale = existing;
687
688 if !stale.is_empty() {
689 let repo = gix::open(repo_dir).map_err(|e| GitError::Open(Box::new(e)))?;
690 let odb = repo.objects.clone().into_inner();
694 for oid in stale {
695 if entry_remains_a_boundary(&repo, &odb, oid) {
696 final_set.insert(oid);
697 }
698 }
699 }
700
701 if final_set.is_empty() {
702 if let Err(e) = std::fs::remove_file(&path)
705 && e.kind() != io::ErrorKind::NotFound
706 {
707 return Err(GitError::Io(e));
708 }
709 return Ok(());
710 }
711
712 let mut sorted: Vec<ObjectId> = final_set.into_iter().collect();
715 sorted.sort_unstable();
716
717 let mut buf = Vec::with_capacity(sorted.len() * SHA1_HEX_LINE_LEN);
718 for oid in &sorted {
719 writeln!(buf, "{}", oid.to_hex()).map_err(GitError::Io)?;
720 }
721 write_atomic(&path, &buf)
722}
723
724fn git_dir_for(repo_dir: &Path) -> PathBuf {
732 let candidate = repo_dir.join(".git");
733 if candidate.is_dir() {
734 return candidate;
735 }
736 if candidate.is_file()
740 && let Ok(content) = std::fs::read_to_string(&candidate)
741 && let Some(rest) = content.trim().strip_prefix("gitdir:")
742 {
743 let pointed = Path::new(rest.trim());
744 let resolved = if pointed.is_absolute() {
745 pointed.to_path_buf()
746 } else {
747 repo_dir.join(pointed)
748 };
749 if resolved.is_dir() {
750 return resolved;
751 }
752 }
753 repo_dir.to_path_buf()
755}
756
757fn entry_remains_a_boundary(
767 repo: &gix::Repository,
768 odb: &impl gix_pack::Find,
769 oid: ObjectId,
770) -> bool {
771 let object = match repo.find_object(oid) {
772 Ok(o) => o,
773 Err(e) => {
774 debug!(%oid, error = %e, "shallow entry not found in ODB; pruning");
775 return false;
776 }
777 };
778 let commit = match object.peel_to_kind(gix::object::Kind::Commit) {
779 Ok(c) => c.into_commit(),
780 Err(e) => {
781 debug!(%oid, error = %e, "shallow entry does not peel to a commit; pruning");
782 return false;
783 }
784 };
785 let mut parents = commit.parent_ids().map(gix::Id::detach).peekable();
789 if parents.peek().is_none() {
790 return false;
791 }
792 parents.any(|p| !odb.contains(&p))
793}
794
795pub fn archive(repo: &Repository, folder: &Path, spec: &str) -> Result<PathBuf, GitError> {
807 let tree = repo
808 .rev_parse_single(BStr::new(spec))?
809 .object()?
810 .peel_to_kind(gix::object::Kind::Tree)?;
811 let (stream, _index) = repo.worktree_stream(tree.id)?;
812
813 let path = folder.join("repo.zip");
814 let file = std::fs::File::create(&path)?;
815 let buf = std::io::BufWriter::new(file);
816
817 let interrupt = AtomicBool::new(false);
818 let options = gix_archive::Options {
819 format: gix_archive::Format::Zip {
820 compression_level: None,
821 },
822 ..gix_archive::Options::default()
823 };
824 repo.worktree_archive(stream, buf, Discard, &interrupt, options)?;
825 Ok(path)
826}
827
828pub fn last_commit_message(repo: &Repository) -> Result<String, GitError> {
838 use gix::head::peel;
839
840 let commit = match repo.head_commit() {
841 Ok(c) => c,
842 Err(gix::reference::head_commit::Error::PeelToCommit(
843 peel::to_commit::Error::PeelToObject(peel::to_object::Error::Unborn { .. }),
844 )) => return Err(GitError::NoCommits),
845 Err(e) => return Err(e.into()),
846 };
847 let short = commit.short_id()?;
848 let message = commit.message()?;
849 Ok(format!("{} {}", short, message.summary().to_str_lossy()))
850}
851
852pub fn remote_url(repo: &Repository, name: &str) -> Result<String, GitError> {
864 let owned_name = || name.to_owned();
865 let remote = repo.find_remote(BStr::new(name)).map_err(|e| match e {
866 gix::remote::find::existing::Error::NotFound { .. } => {
867 GitError::RemoteNotFound(owned_name())
868 }
869 other => GitError::FindRemote(Box::new(other)),
870 })?;
871 let url = remote
872 .url(Direction::Fetch)
873 .or_else(|| remote.url(Direction::Push))
874 .ok_or_else(|| GitError::RemoteHasNoUrl(owned_name()))?;
875 String::from_utf8(url.to_bstring().into()).map_err(|source| GitError::NonUtf8RemoteUrl {
876 remote: owned_name(),
877 source,
878 })
879}
880
881struct DottedKey<'a> {
889 section: &'a str,
890 subsection: Option<&'a str>,
891 name: &'a str,
892}
893
894fn parse_dotted_key(key: &str) -> Result<DottedKey<'_>, GitError> {
895 let first_dot = key
896 .find('.')
897 .ok_or_else(|| GitError::ConfigKeyParse(key.to_owned()))?;
898 let last_dot = key
899 .rfind('.')
900 .expect("first_dot found, so rfind cannot be None");
901 let section = &key[..first_dot];
902 let name = &key[last_dot + 1..];
903 if section.is_empty() || name.is_empty() {
904 return Err(GitError::ConfigKeyParse(key.to_owned()));
905 }
906 let subsection = (first_dot != last_dot).then(|| &key[first_dot + 1..last_dot]);
910 Ok(DottedKey {
911 section,
912 subsection,
913 name,
914 })
915}
916
917fn config_path_for_cwd(cwd: &Path) -> Result<PathBuf, GitError> {
923 let repo = gix::discover(cwd)?;
924 Ok(repo.common_dir().join("config"))
925}
926
927fn read_or_empty(path: &Path) -> Result<Vec<u8>, GitError> {
928 match std::fs::read(path) {
929 Ok(bytes) => Ok(bytes),
930 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
931 Err(e) => Err(GitError::Io(e)),
932 }
933}
934
935fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), GitError> {
939 use std::io::Write;
940 let mut lock = gix_lock::File::acquire_to_update_resource(
941 path,
942 gix_lock::acquire::Fail::Immediately,
943 None,
944 )?;
945 lock.write_all(bytes).map_err(GitError::Io)?;
946 lock.commit().map_err(|e| GitError::Io(e.error))?;
947 Ok(())
948}
949
950pub fn config_add(cwd: &Path, key: &str, value: &str) -> Result<(), GitError> {
971 config_add_many(cwd, &[(key, value)])
972}
973
974pub fn config_add_many(cwd: &Path, entries: &[(&str, &str)]) -> Result<(), GitError> {
993 apply_config_entries(cwd, entries, |file, parsed| {
994 for (parts, value_name, value) in parsed {
995 let subsection = parts.subsection.map(BStr::new);
996 let mut section = file
997 .section_mut_or_create_new(parts.section, subsection)
998 .map_err(|source| GitError::ConfigInvalidSectionName {
999 name: parts.section.to_owned(),
1000 source,
1001 })?;
1002 section.push(value_name.clone(), Some(BStr::new(value)));
1003 }
1004 Ok(true)
1005 })
1006}
1007
1008fn apply_config_entries<F>(cwd: &Path, entries: &[(&str, &str)], mutate: F) -> Result<(), GitError>
1019where
1020 F: for<'a> FnOnce(
1021 &mut gix::config::File<'a>,
1022 &[(DottedKey<'a>, ValueName<'a>, &'a str)],
1023 ) -> Result<bool, GitError>,
1024{
1025 if entries.is_empty() {
1026 return Ok(());
1027 }
1028 let parsed: Vec<(DottedKey<'_>, ValueName<'_>, &str)> = entries
1029 .iter()
1030 .map(|(key, value)| {
1031 let parts = parse_dotted_key(key)?;
1032 let value_name = ValueName::try_from(parts.name).map_err(|source| {
1033 GitError::ConfigInvalidValueName {
1034 name: parts.name.to_owned(),
1035 source,
1036 }
1037 })?;
1038 Ok::<_, GitError>((parts, value_name, *value))
1039 })
1040 .collect::<Result<_, _>>()?;
1041
1042 let config_path = config_path_for_cwd(cwd)?;
1043 let bytes = read_or_empty(&config_path)?;
1044 let mut file = gix::config::File::from_bytes_no_includes(
1045 &bytes,
1046 GixConfigMetadata::api(),
1047 gix_config_init::Options::default(),
1048 )?;
1049
1050 if !mutate(&mut file, &parsed)? {
1051 return Ok(());
1052 }
1053
1054 let extra: usize = entries.iter().map(|(k, v)| k.len() + v.len() + 16).sum();
1055 let mut serialized = Vec::with_capacity(bytes.len() + extra);
1056 file.write_to(&mut serialized).map_err(GitError::Io)?;
1057 write_atomic(&config_path, &serialized)
1058}
1059
1060pub fn config_unset(cwd: &Path, key: &str) -> Result<(), GitError> {
1082 let parts = parse_dotted_key(key)?;
1083 let config_path = config_path_for_cwd(cwd)?;
1084 let bytes = read_or_empty(&config_path)?;
1085 let mut file = gix::config::File::from_bytes_no_includes(
1086 &bytes,
1087 GixConfigMetadata::api(),
1088 gix_config_init::Options::default(),
1089 )?;
1090 let subsection = parts.subsection.map(BStr::new);
1091 let Ok(mut section) = file.section_mut(parts.section, subsection) else {
1092 return Err(GitError::ConfigKeyNotSet(key.to_owned()));
1093 };
1094 if section.remove(parts.name).is_none() {
1095 return Err(GitError::ConfigKeyNotSet(key.to_owned()));
1096 }
1097
1098 let mut serialized = Vec::with_capacity(bytes.len());
1099 file.write_to(&mut serialized).map_err(GitError::Io)?;
1100 write_atomic(&config_path, &serialized)
1101}
1102
1103pub fn config_unset_if_present(cwd: &Path, key: &str) -> Result<(), GitError> {
1116 match config_unset(cwd, key) {
1117 Ok(()) | Err(GitError::ConfigKeyNotSet(_)) => Ok(()),
1118 Err(e) => Err(e),
1119 }
1120}
1121
1122pub fn config_set(cwd: &Path, key: &str, value: &str) -> Result<(), GitError> {
1135 config_set_many(cwd, &[(key, value)])
1136}
1137
1138pub fn config_set_many(cwd: &Path, entries: &[(&str, &str)]) -> Result<(), GitError> {
1162 apply_config_entries(cwd, entries, |file, parsed| {
1163 let mut changed = false;
1164 for (parts, value_name, value) in parsed {
1165 let subsection = parts.subsection.map(BStr::new);
1166 let mut section = file
1167 .section_mut_or_create_new(parts.section, subsection)
1168 .map_err(|source| GitError::ConfigInvalidSectionName {
1169 name: parts.section.to_owned(),
1170 source,
1171 })?;
1172 let existing = section.values(parts.name);
1173 if existing.len() == 1 && existing[0].as_ref() == value.as_bytes() {
1176 continue;
1177 }
1178 while section.remove(parts.name).is_some() {}
1182 section.push(value_name.clone(), Some(BStr::new(value)));
1183 changed = true;
1184 }
1185 Ok(changed)
1186 })
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191 use super::*;
1192
1193 use gix::actor::SignatureRef;
1194 use gix::bstr::BStr;
1195 use gix_pack::Find as _;
1196 use std::sync::OnceLock;
1197 use tempfile::TempDir;
1198
1199 fn signature() -> SignatureRef<'static> {
1200 SignatureRef {
1201 name: BStr::new("Test"),
1202 email: BStr::new("test@example.com"),
1203 time: "0 +0000",
1204 }
1205 }
1206
1207 fn empty_repo() -> (Repository, TempDir) {
1208 let dir = TempDir::new().expect("tempdir");
1209 let repo = gix::init(dir.path()).expect("gix::init");
1210 (repo, dir)
1211 }
1212
1213 fn make_marker_tree(repo: &Repository) -> ObjectId {
1218 use gix::objs::tree::{Entry, EntryKind};
1219 let blob_id = repo.write_blob(b"hello\n").expect("write blob").detach();
1220 let tree = gix::objs::Tree {
1221 entries: vec![Entry {
1222 mode: EntryKind::Blob.into(),
1223 filename: "marker".into(),
1224 oid: blob_id,
1225 }],
1226 };
1227 repo.write_object(&tree).expect("write tree").detach()
1228 }
1229
1230 fn add_commit(
1231 repo: &Repository,
1232 ref_name: &str,
1233 parents: &[ObjectId],
1234 message: &str,
1235 ) -> ObjectId {
1236 let tree_id = make_marker_tree(repo);
1237 let id = repo
1238 .commit_as(
1239 signature(),
1240 signature(),
1241 ref_name,
1242 message,
1243 tree_id,
1244 parents.iter().copied(),
1245 )
1246 .expect("commit_as");
1247 id.detach()
1248 }
1249
1250 fn commit_with_synthetic_parents(
1256 repo: &Repository,
1257 parents: &[ObjectId],
1258 message: &str,
1259 ) -> ObjectId {
1260 let tree_id = make_marker_tree(repo);
1261 let sig = gix::actor::Signature {
1262 name: "Test".into(),
1263 email: "test@example.com".into(),
1264 time: gix::date::Time::default(),
1265 };
1266 let commit = gix::objs::Commit {
1267 tree: tree_id,
1268 parents: parents.iter().copied().collect(),
1269 author: sig.clone(),
1270 committer: sig,
1271 encoding: None,
1272 message: message.into(),
1273 extra_headers: Vec::new(),
1274 };
1275 repo.write_object(&commit).expect("write commit").detach()
1276 }
1277
1278 fn git_available() -> bool {
1279 static AVAIL: OnceLock<bool> = OnceLock::new();
1280 *AVAIL.get_or_init(|| {
1281 std::process::Command::new("git")
1282 .arg("--version")
1283 .output()
1284 .is_ok()
1285 })
1286 }
1287
1288 #[test]
1291 fn sha_from_hex_accepts_valid_lowercase_sha1() {
1292 let s = Sha::from_hex("0123456789abcdef0123456789abcdef01234567").expect("valid");
1293 assert_eq!(s.to_string(), "0123456789abcdef0123456789abcdef01234567");
1294 }
1295
1296 #[test]
1297 fn sha_from_hex_accepts_uppercase_and_normalizes_to_lowercase() {
1298 let s = Sha::from_hex("0123456789ABCDEF0123456789ABCDEF01234567").expect("valid");
1299 assert_eq!(s.to_string(), "0123456789abcdef0123456789abcdef01234567");
1300 }
1301
1302 #[test]
1303 fn sha_from_hex_rejects_wrong_length() {
1304 assert!(Sha::from_hex("abc").is_err());
1305 assert!(Sha::from_hex(&"a".repeat(39)).is_err());
1306 assert!(Sha::from_hex(&"a".repeat(41)).is_err());
1307 }
1308
1309 #[test]
1310 fn sha_from_hex_rejects_non_hex() {
1311 assert!(Sha::from_hex(&"g".repeat(40)).is_err());
1312 assert!(Sha::from_hex("0123456789abcdef0123456789abcdef0123456 ").is_err());
1313 }
1314
1315 #[test]
1316 fn sha_from_hex_rejects_empty() {
1317 assert!(matches!(Sha::from_hex(""), Err(ShaError::Empty)));
1318 }
1319
1320 const INVALID_REF_NAMES: &[&str] = &[
1323 "",
1324 ".hidden",
1325 "refs/heads/.hidden",
1326 "refs/heads/foo..bar",
1327 "refs/heads/foo bar",
1328 "refs/heads/",
1329 "refs/heads/main.lock",
1330 "refs/heads/main@{x}",
1331 "refs/heads//main",
1332 "refs/heads/main\x01",
1333 "refs/heads/?bad",
1334 "refs/heads/[bad]",
1335 "refs/heads/^bad",
1336 "refs/heads/~bad",
1337 "refs/heads/*bad",
1338 "refs/heads/:bad",
1339 ];
1340
1341 #[test]
1342 fn ref_name_new_accepts_canonical_refs() {
1343 assert!(RefName::new("refs/heads/main").is_ok());
1344 assert!(RefName::new("refs/heads/feature/x").is_ok());
1345 assert!(RefName::new("refs/tags/v1").is_ok());
1346 }
1347
1348 #[test]
1349 fn ref_name_new_rejects_each_invalid_category() {
1350 for name in INVALID_REF_NAMES {
1351 assert!(
1352 RefName::new(*name).is_err(),
1353 "expected RefName::new({name:?}) to fail",
1354 );
1355 }
1356 }
1357
1358 #[test]
1359 fn ref_name_is_valid_matches_new() {
1360 for name in ["refs/heads/main", "refs/heads/feature/x", "refs/tags/v1"] {
1365 assert!(RefName::is_valid(name), "expected is_valid({name:?})");
1366 }
1367 for name in INVALID_REF_NAMES {
1368 assert!(!RefName::is_valid(name), "expected !is_valid({name:?})");
1369 }
1370 }
1371
1372 #[test]
1373 fn is_valid_ref_name_partial_accepts_single_component_head() {
1374 assert!(is_valid_ref_name("HEAD"));
1377 }
1378
1379 #[test]
1380 fn is_valid_ref_name_partial_rejects_each_invalid_category() {
1381 for name in &[
1383 "",
1384 "refs/heads/.hidden",
1385 "refs/heads/foo..bar",
1386 "refs/heads/main.lock",
1387 ] {
1388 assert!(!is_valid_ref_name(name), "expected !{name:?}");
1389 }
1390 }
1391
1392 #[test]
1395 fn is_ancestor_self_is_true() {
1396 let (repo, _dir) = empty_repo();
1397 let a = add_commit(&repo, "refs/heads/main", &[], "first");
1398 let sa = Sha::from_object_id(a);
1399 assert!(is_ancestor(&repo, sa, sa).expect("is_ancestor"));
1400 }
1401
1402 #[test]
1403 fn is_ancestor_parent_of_child_is_true() {
1404 let (repo, _dir) = empty_repo();
1405 let a = add_commit(&repo, "refs/heads/main", &[], "a");
1406 let b = add_commit(&repo, "refs/heads/main", &[a], "b");
1407 assert!(
1408 is_ancestor(&repo, Sha::from_object_id(a), Sha::from_object_id(b))
1409 .expect("is_ancestor")
1410 );
1411 }
1412
1413 #[test]
1414 fn is_ancestor_reverse_is_false() {
1415 let (repo, _dir) = empty_repo();
1416 let a = add_commit(&repo, "refs/heads/main", &[], "a");
1417 let b = add_commit(&repo, "refs/heads/main", &[a], "b");
1418 assert!(
1419 !is_ancestor(&repo, Sha::from_object_id(b), Sha::from_object_id(a))
1420 .expect("is_ancestor")
1421 );
1422 }
1423
1424 #[test]
1425 fn is_ancestor_unrelated_is_false() {
1426 let (repo, _dir) = empty_repo();
1427 let a = add_commit(&repo, "refs/heads/main", &[], "a");
1428 let b = add_commit(&repo, "refs/heads/side", &[], "b");
1429 assert!(
1430 !is_ancestor(&repo, Sha::from_object_id(a), Sha::from_object_id(b))
1431 .expect("is_ancestor")
1432 );
1433 }
1434
1435 fn write_annotated_tag(
1438 repo: &Repository,
1439 target: ObjectId,
1440 target_kind: gix::object::Kind,
1441 name: &str,
1442 ) -> ObjectId {
1443 let tag = gix::objs::Tag {
1444 target,
1445 target_kind,
1446 name: name.into(),
1447 tagger: Some(signature().to_owned().expect("static signature is valid")),
1448 message: "test".into(),
1449 pgp_signature: None,
1450 };
1451 repo.write_object(&tag).expect("write tag").detach()
1452 }
1453
1454 #[test]
1455 fn peel_lightweight_tag_returns_commit_with_empty_chain() {
1456 let (repo, _dir) = empty_repo();
1461 let commit = add_commit(&repo, "refs/heads/main", &[], "c");
1462 let peeled = peel_tag_chain(&repo, Sha::from_object_id(commit)).expect("peel");
1463 match peeled {
1464 PeeledTip::Commit {
1465 commit: peeled_commit,
1466 tag_chain,
1467 } => {
1468 assert_eq!(peeled_commit.as_object_id(), &commit);
1469 assert!(tag_chain.is_empty());
1470 }
1471 other => panic!("expected Commit variant, got {:?}", variant_name(&other)),
1472 }
1473 }
1474
1475 #[test]
1476 fn peel_annotated_tag_returns_commit_with_one_element_chain() {
1477 let (repo, _dir) = empty_repo();
1478 let commit = add_commit(&repo, "refs/heads/main", &[], "c");
1479 let tag = write_annotated_tag(&repo, commit, gix::object::Kind::Commit, "v1");
1480 let peeled = peel_tag_chain(&repo, Sha::from_object_id(tag)).expect("peel");
1481 match peeled {
1482 PeeledTip::Commit {
1483 commit: peeled_commit,
1484 tag_chain,
1485 } => {
1486 assert_eq!(peeled_commit.as_object_id(), &commit);
1487 assert_eq!(tag_chain, vec![tag]);
1488 }
1489 other => panic!("expected Commit variant, got {:?}", variant_name(&other)),
1490 }
1491 }
1492
1493 #[test]
1494 fn peel_tag_of_tag_returns_commit_with_outer_then_inner_chain() {
1495 let (repo, _dir) = empty_repo();
1496 let commit = add_commit(&repo, "refs/heads/main", &[], "c");
1497 let inner = write_annotated_tag(&repo, commit, gix::object::Kind::Commit, "inner");
1498 let outer = write_annotated_tag(&repo, inner, gix::object::Kind::Tag, "outer");
1499 let peeled = peel_tag_chain(&repo, Sha::from_object_id(outer)).expect("peel");
1500 match peeled {
1501 PeeledTip::Commit {
1502 commit: peeled_commit,
1503 tag_chain,
1504 } => {
1505 assert_eq!(peeled_commit.as_object_id(), &commit);
1506 assert_eq!(tag_chain, vec![outer, inner]);
1508 }
1509 other => panic!("expected Commit variant, got {:?}", variant_name(&other)),
1510 }
1511 }
1512
1513 fn write_tree_with_one_blob(repo: &gix::Repository) -> (ObjectId, ObjectId) {
1515 use gix::objs::tree::{Entry, EntryKind};
1516 let blob = repo.write_blob(b"x").expect("write blob").detach();
1517 let tree = repo
1518 .write_object(&gix::objs::Tree {
1519 entries: vec![Entry {
1520 mode: EntryKind::Blob.into(),
1521 filename: "x".into(),
1522 oid: blob,
1523 }],
1524 })
1525 .expect("write tree")
1526 .detach();
1527 (tree, blob)
1528 }
1529
1530 #[test]
1531 fn peel_tag_pointing_to_tree_returns_tree_variant() {
1532 let (repo, _dir) = empty_repo();
1533 let (tree_id, _blob) = write_tree_with_one_blob(&repo);
1534 let tag = write_annotated_tag(&repo, tree_id, gix::object::Kind::Tree, "tree-tag");
1535 let peeled = peel_tag_chain(&repo, Sha::from_object_id(tag)).expect("peel");
1536 match peeled {
1537 PeeledTip::Tree { tree, tag_chain } => {
1538 assert_eq!(tree, tree_id);
1539 assert_eq!(tag_chain, vec![tag]);
1540 }
1541 other => panic!("expected Tree variant, got {:?}", variant_name(&other)),
1542 }
1543 }
1544
1545 #[test]
1546 fn peel_tag_pointing_to_blob_returns_blob_variant() {
1547 let (repo, _dir) = empty_repo();
1548 let blob_id = repo.write_blob(b"data").expect("write blob").detach();
1549 let tag = write_annotated_tag(&repo, blob_id, gix::object::Kind::Blob, "blob-tag");
1550 let peeled = peel_tag_chain(&repo, Sha::from_object_id(tag)).expect("peel");
1551 match peeled {
1552 PeeledTip::Blob { blob, tag_chain } => {
1553 assert_eq!(blob, blob_id);
1554 assert_eq!(tag_chain, vec![tag]);
1555 }
1556 other => panic!("expected Blob variant, got {:?}", variant_name(&other)),
1557 }
1558 }
1559
1560 #[test]
1561 fn peel_tag_of_tag_of_tree_returns_tree_with_outer_then_inner_chain() {
1562 let (repo, _dir) = empty_repo();
1563 let (tree_id, _blob) = write_tree_with_one_blob(&repo);
1564 let inner = write_annotated_tag(&repo, tree_id, gix::object::Kind::Tree, "inner");
1565 let outer = write_annotated_tag(&repo, inner, gix::object::Kind::Tag, "outer");
1566 let peeled = peel_tag_chain(&repo, Sha::from_object_id(outer)).expect("peel");
1567 match peeled {
1568 PeeledTip::Tree { tree, tag_chain } => {
1569 assert_eq!(tree, tree_id);
1570 assert_eq!(tag_chain, vec![outer, inner]);
1571 }
1572 other => panic!("expected Tree variant, got {:?}", variant_name(&other)),
1573 }
1574 }
1575
1576 #[test]
1577 fn peel_depth_three_tag_chain_to_blob_preserves_chain_order() {
1578 let (repo, _dir) = empty_repo();
1581 let blob_id = repo.write_blob(b"data").expect("write blob").detach();
1582 let inner = write_annotated_tag(&repo, blob_id, gix::object::Kind::Blob, "inner");
1583 let middle = write_annotated_tag(&repo, inner, gix::object::Kind::Tag, "middle");
1584 let outer = write_annotated_tag(&repo, middle, gix::object::Kind::Tag, "outer");
1585 let peeled = peel_tag_chain(&repo, Sha::from_object_id(outer)).expect("peel");
1586 match peeled {
1587 PeeledTip::Blob { blob, tag_chain } => {
1588 assert_eq!(blob, blob_id);
1589 assert_eq!(tag_chain, vec![outer, middle, inner]);
1590 }
1591 other => panic!("expected Blob variant, got {:?}", variant_name(&other)),
1592 }
1593 }
1594
1595 #[test]
1596 fn peel_bare_tree_ref_returns_tree_with_empty_chain() {
1597 let (repo, _dir) = empty_repo();
1601 let (tree_id, _blob) = write_tree_with_one_blob(&repo);
1602 let peeled = peel_tag_chain(&repo, Sha::from_object_id(tree_id)).expect("peel");
1603 match peeled {
1604 PeeledTip::Tree { tree, tag_chain } => {
1605 assert_eq!(tree, tree_id);
1606 assert!(tag_chain.is_empty());
1607 }
1608 other => panic!("expected Tree variant, got {:?}", variant_name(&other)),
1609 }
1610 }
1611
1612 #[test]
1613 fn peel_bare_blob_ref_returns_blob_with_empty_chain() {
1614 let (repo, _dir) = empty_repo();
1615 let blob_id = repo.write_blob(b"data").expect("write blob").detach();
1616 let peeled = peel_tag_chain(&repo, Sha::from_object_id(blob_id)).expect("peel");
1617 match peeled {
1618 PeeledTip::Blob { blob, tag_chain } => {
1619 assert_eq!(blob, blob_id);
1620 assert!(tag_chain.is_empty());
1621 }
1622 other => panic!("expected Blob variant, got {:?}", variant_name(&other)),
1623 }
1624 }
1625
1626 fn variant_name(p: &PeeledTip) -> &'static str {
1627 match p {
1628 PeeledTip::Commit { .. } => "Commit",
1629 PeeledTip::Tree { .. } => "Tree",
1630 PeeledTip::Blob { .. } => "Blob",
1631 }
1632 }
1633
1634 #[test]
1635 fn archive_writes_repo_zip_with_pk_header() {
1636 let (repo, dir) = empty_repo();
1637 add_commit(&repo, "refs/heads/main", &[], "first");
1638 let out_dir = TempDir::new().expect("tempdir");
1639 let zip_path = archive(&repo, out_dir.path(), "refs/heads/main").expect("archive");
1640 assert_eq!(zip_path, out_dir.path().join("repo.zip"));
1641 let bytes = std::fs::read(&zip_path).expect("read zip");
1642 assert_eq!(&bytes[..4], b"PK\x03\x04", "zip local-file-header missing");
1643 drop(dir);
1644 }
1645
1646 #[test]
1647 fn archive_resolves_tag_through_peel() {
1648 let (repo, _dir) = empty_repo();
1652 let commit_oid = add_commit(&repo, "refs/heads/main", &[], "first");
1653 let tag = gix::objs::Tag {
1654 target: commit_oid,
1655 target_kind: gix::object::Kind::Commit,
1656 name: "v1".into(),
1657 tagger: Some(signature().to_owned().expect("static signature is valid")),
1658 message: "release".into(),
1659 pgp_signature: None,
1660 };
1661 let tag_id = repo.write_object(&tag).expect("write tag").detach();
1662 repo.reference(
1663 "refs/tags/v1",
1664 tag_id,
1665 gix::refs::transaction::PreviousValue::MustNotExist,
1666 "create tag",
1667 )
1668 .expect("create tag ref");
1669 let out_dir = TempDir::new().expect("tempdir");
1670 let zip_path = archive(&repo, out_dir.path(), "refs/tags/v1").expect("archive tag");
1671 let bytes = std::fs::read(&zip_path).expect("read zip");
1672 assert_eq!(&bytes[..4], b"PK\x03\x04");
1673 }
1674
1675 #[test]
1676 fn last_commit_message_format_short_sha_then_subject() {
1677 let (repo, _dir) = empty_repo();
1678 add_commit(&repo, "refs/heads/main", &[], "Initial commit");
1679 let msg = last_commit_message(&repo).expect("last_commit_message");
1680 let mut parts = msg.splitn(2, ' ');
1681 let short = parts.next().expect("short");
1682 let subject = parts.next().expect("subject");
1683 assert!(short.len() >= 4, "short id too short: {short:?}");
1684 assert!(short.chars().all(|c| c.is_ascii_hexdigit()));
1685 assert_eq!(subject, "Initial commit");
1686 }
1687
1688 #[test]
1689 fn last_commit_message_unborn_head_returns_no_commits() {
1690 let (repo, _dir) = empty_repo();
1691 assert!(matches!(
1692 last_commit_message(&repo),
1693 Err(GitError::NoCommits)
1694 ));
1695 }
1696
1697 #[test]
1698 fn remote_url_returns_fetch_url() {
1699 let (repo, dir) = empty_repo();
1700 let url = "https://example.com/repo.git";
1701 let config_path = repo.git_dir().join("config");
1702 let existing = std::fs::read_to_string(&config_path).expect("read config");
1703 let amended = format!(
1704 "{existing}\n[remote \"origin\"]\n\turl = {url}\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n"
1705 );
1706 std::fs::write(&config_path, amended).expect("write config");
1707 let repo = gix::open(repo.git_dir()).expect("re-open");
1709 let got = remote_url(&repo, "origin").expect("remote_url");
1710 assert_eq!(got, url);
1711 drop(dir);
1712 }
1713
1714 #[test]
1715 fn remote_url_unknown_remote_returns_remote_not_found() {
1716 let (repo, _dir) = empty_repo();
1717 assert!(matches!(
1718 remote_url(&repo, "missing"),
1719 Err(GitError::RemoteNotFound(_))
1720 ));
1721 }
1722
1723 #[test]
1724 fn remote_url_falls_back_to_push_url_when_fetch_url_absent() {
1725 let (repo, dir) = empty_repo();
1729 let push_url = "https://example.com/push.git";
1730 let config_path = repo.git_dir().join("config");
1731 let existing = std::fs::read_to_string(&config_path).expect("read config");
1732 let amended = format!("{existing}\n[remote \"only-push\"]\n\tpushurl = {push_url}\n");
1733 std::fs::write(&config_path, amended).expect("write config");
1734 let repo = gix::open(repo.git_dir()).expect("re-open");
1735 let got = remote_url(&repo, "only-push").expect("remote_url");
1736 assert_eq!(got, push_url);
1737 drop(dir);
1738 }
1739
1740 #[test]
1743 fn parse_dotted_key_two_segments_has_no_subsection() {
1744 let p = parse_dotted_key("lfs.standalonetransferagent").expect("parse");
1745 assert_eq!(p.section, "lfs");
1746 assert_eq!(p.subsection, None);
1747 assert_eq!(p.name, "standalonetransferagent");
1748 }
1749
1750 #[test]
1751 fn parse_dotted_key_three_segments_uses_middle_as_subsection() {
1752 let p = parse_dotted_key("remote.origin.url").expect("parse");
1753 assert_eq!(p.section, "remote");
1754 assert_eq!(p.subsection, Some("origin"));
1755 assert_eq!(p.name, "url");
1756 }
1757
1758 #[test]
1759 fn parse_dotted_key_four_segments_joins_subsection_with_dots() {
1760 let p = parse_dotted_key("lfs.customtransfer.git-lfs-object-store.path").expect("parse");
1763 assert_eq!(p.section, "lfs");
1764 assert_eq!(p.subsection, Some("customtransfer.git-lfs-object-store"));
1765 assert_eq!(p.name, "path");
1766 }
1767
1768 #[test]
1769 fn parse_dotted_key_rejects_invalid_shapes() {
1770 for bad in ["", "nodotsegment", ".name", "section.", "."] {
1775 assert!(
1776 matches!(parse_dotted_key(bad), Err(GitError::ConfigKeyParse(_))),
1777 "expected parse failure for {bad:?}",
1778 );
1779 }
1780 }
1781
1782 #[test]
1783 fn parse_dotted_key_accepts_empty_subsection_for_git_parity() {
1784 let p = parse_dotted_key("a..b").expect("parse");
1787 assert_eq!(p.section, "a");
1788 assert_eq!(p.subsection, Some(""));
1789 assert_eq!(p.name, "b");
1790 }
1791
1792 fn read_local_config(repo: &Repository) -> String {
1798 let path = repo.common_dir().join("config");
1799 std::fs::read_to_string(&path).expect("read config")
1800 }
1801
1802 fn config_values(repo: &Repository, key: &str) -> Vec<String> {
1807 let path = repo.common_dir().join("config");
1808 let bytes = std::fs::read(&path).expect("read config");
1809 let file = gix::config::File::from_bytes_no_includes(
1810 &bytes,
1811 GixConfigMetadata::api(),
1812 gix_config_init::Options::default(),
1813 )
1814 .expect("parse");
1815 file.raw_values(key)
1816 .map(|values| {
1817 values
1818 .into_iter()
1819 .map(|v| v.into_owned().to_string())
1820 .collect()
1821 })
1822 .unwrap_or_default()
1823 }
1824
1825 #[test]
1826 fn config_add_creates_section_and_value() {
1827 let (repo, _dir) = empty_repo();
1828 config_add(
1829 repo.workdir().expect("workdir"),
1830 "lfs.standalonetransferagent",
1831 "git-lfs-object-store",
1832 )
1833 .expect("config_add");
1834 let values = config_values(&repo, "lfs.standalonetransferagent");
1835 assert_eq!(values, vec!["git-lfs-object-store".to_owned()]);
1836 }
1837
1838 #[test]
1839 fn config_add_handles_two_level_subsection() {
1840 let (repo, _dir) = empty_repo();
1841 let key = "lfs.customtransfer.git-lfs-object-store.path";
1842 config_add(
1843 repo.workdir().expect("workdir"),
1844 key,
1845 "git-lfs-object-store",
1846 )
1847 .expect("config_add");
1848 let values = config_values(&repo, key);
1849 assert_eq!(values, vec!["git-lfs-object-store".to_owned()]);
1850 }
1851
1852 #[test]
1853 fn config_add_appends_duplicate_values() {
1854 let (repo, _dir) = empty_repo();
1857 let cwd = repo.workdir().expect("workdir");
1858 config_add(cwd, "lfs.standalonetransferagent", "first").expect("first");
1859 config_add(cwd, "lfs.standalonetransferagent", "second").expect("second");
1860 let values = config_values(&repo, "lfs.standalonetransferagent");
1861 assert_eq!(values, vec!["first".to_owned(), "second".to_owned()]);
1862 }
1863
1864 #[test]
1865 fn config_add_preserves_existing_comments() {
1866 let (repo, _dir) = empty_repo();
1867 let path = repo.common_dir().join("config");
1868 let existing = std::fs::read_to_string(&path).expect("read config");
1869 let amended = format!("{existing}# user marker\n[user]\n\tname = Tester\n");
1870 std::fs::write(&path, amended).expect("seed config");
1871
1872 config_add(
1873 repo.workdir().expect("workdir"),
1874 "lfs.standalonetransferagent",
1875 "git-lfs-object-store",
1876 )
1877 .expect("config_add");
1878
1879 let after = read_local_config(&repo);
1880 assert!(
1881 after.contains("# user marker"),
1882 "comment dropped: {after:?}"
1883 );
1884 assert!(
1885 after.contains("name = Tester"),
1886 "user.name dropped: {after:?}"
1887 );
1888 let values = config_values(&repo, "lfs.standalonetransferagent");
1889 assert_eq!(values, vec!["git-lfs-object-store".to_owned()]);
1890 }
1891
1892 #[test]
1893 fn config_add_rejects_invalid_key() {
1894 let (repo, _dir) = empty_repo();
1895 assert!(matches!(
1896 config_add(repo.workdir().expect("workdir"), "", "v"),
1897 Err(GitError::ConfigKeyParse(_))
1898 ));
1899 assert!(matches!(
1900 config_add(repo.workdir().expect("workdir"), "nodot", "v"),
1901 Err(GitError::ConfigKeyParse(_))
1902 ));
1903 }
1904
1905 #[test]
1906 fn config_add_rejects_invalid_value_name() {
1907 let (repo, _dir) = empty_repo();
1910 let err = config_add(repo.workdir().expect("workdir"), "lfs.123bad", "v")
1911 .expect_err("expected validation error");
1912 assert!(
1913 matches!(err, GitError::ConfigInvalidValueName { .. }),
1914 "got {err:?}"
1915 );
1916 }
1917
1918 #[test]
1919 fn config_add_many_writes_all_entries_in_one_pass() {
1920 let (repo, _dir) = empty_repo();
1927 let entries: &[(&str, &str)] = &[
1928 (
1929 "lfs.customtransfer.git-lfs-object-store.path",
1930 "git-lfs-object-store",
1931 ),
1932 ("lfs.standalonetransferagent", "git-lfs-object-store"),
1933 ];
1934 config_add_many(repo.workdir().expect("workdir"), entries).expect("config_add_many");
1935 for (key, value) in entries {
1936 assert_eq!(config_values(&repo, key), vec![(*value).to_owned()]);
1937 }
1938 }
1939
1940 #[test]
1941 fn config_add_many_validates_all_entries_before_writing() {
1942 let (repo, _dir) = empty_repo();
1947 let cwd = repo.workdir().expect("workdir");
1948 let path_before = read_local_config(&repo);
1949 let err = config_add_many(
1950 cwd,
1951 &[
1952 ("lfs.standalonetransferagent", "git-lfs-object-store"),
1953 ("nodot", "v"),
1954 ],
1955 )
1956 .expect_err("expected parse failure on second entry");
1957 assert!(matches!(err, GitError::ConfigKeyParse(_)), "got {err:?}");
1958 assert_eq!(read_local_config(&repo), path_before);
1959 assert!(
1960 config_values(&repo, "lfs.standalonetransferagent").is_empty(),
1961 "first entry should not have been written",
1962 );
1963 }
1964
1965 #[test]
1966 fn config_add_many_empty_input_is_noop() {
1967 let (repo, _dir) = empty_repo();
1968 let cwd = repo.workdir().expect("workdir");
1969 let before = read_local_config(&repo);
1970 config_add_many(cwd, &[]).expect("noop");
1971 assert_eq!(read_local_config(&repo), before);
1972 }
1973
1974 #[test]
1975 fn config_unset_removes_existing_value() {
1976 let (repo, _dir) = empty_repo();
1977 let cwd = repo.workdir().expect("workdir");
1978 config_add(cwd, "lfs.customtransfer.git-lfs-object-store.args", "debug").expect("seed");
1979 config_unset(cwd, "lfs.customtransfer.git-lfs-object-store.args").expect("unset");
1980 let values = config_values(&repo, "lfs.customtransfer.git-lfs-object-store.args");
1981 assert!(values.is_empty(), "value still present: {values:?}");
1982 }
1983
1984 #[test]
1985 fn config_unset_missing_key_returns_typed_error() {
1986 let (repo, _dir) = empty_repo();
1987 let err = config_unset(repo.workdir().expect("workdir"), "lfs.never.set")
1988 .expect_err("expected error");
1989 assert!(matches!(err, GitError::ConfigKeyNotSet(ref k) if k == "lfs.never.set"));
1990 }
1991
1992 #[test]
1993 fn config_unset_missing_section_returns_typed_error() {
1994 let (repo, _dir) = empty_repo();
1995 let err = config_unset(repo.workdir().expect("workdir"), "ghost.value")
1999 .expect_err("expected error");
2000 assert!(matches!(err, GitError::ConfigKeyNotSet(_)), "got {err:?}");
2001 }
2002
2003 #[test]
2004 fn config_unset_missing_key_within_existing_section_returns_typed_error() {
2005 let (repo, _dir) = empty_repo();
2011 let cwd = repo.workdir().expect("workdir");
2012 config_add(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("seed");
2013 let err = config_unset(cwd, "lfs.othervalue").expect_err("expected error");
2014 assert!(
2015 matches!(err, GitError::ConfigKeyNotSet(ref k) if k == "lfs.othervalue"),
2016 "got {err:?}"
2017 );
2018 }
2019
2020 #[test]
2021 fn config_add_then_native_git_can_read_value() {
2022 if !git_available() {
2025 eprintln!("skipping: git not on PATH");
2026 return;
2027 }
2028 let (repo, _dir) = empty_repo();
2029 let cwd = repo.workdir().expect("workdir");
2030 config_add(
2031 cwd,
2032 "lfs.customtransfer.git-lfs-object-store.path",
2033 "git-lfs-object-store",
2034 )
2035 .expect("config_add");
2036
2037 let output = std::process::Command::new("git")
2038 .args([
2039 "config",
2040 "--get",
2041 "lfs.customtransfer.git-lfs-object-store.path",
2042 ])
2043 .current_dir(cwd)
2044 .output()
2045 .expect("git config --get");
2046 assert!(
2047 output.status.success(),
2048 "stderr: {}",
2049 String::from_utf8_lossy(&output.stderr)
2050 );
2051 let stdout = String::from_utf8(output.stdout).expect("utf8");
2052 assert_eq!(stdout.trim(), "git-lfs-object-store");
2053 }
2054
2055 #[test]
2063 fn config_set_writes_value_when_key_absent() {
2064 let (repo, _dir) = empty_repo();
2065 let cwd = repo.workdir().expect("workdir");
2066 config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("config_set");
2067 assert_eq!(
2068 config_values(&repo, "lfs.standalonetransferagent"),
2069 vec!["git-lfs-object-store".to_owned()],
2070 );
2071 }
2072
2073 #[test]
2074 fn config_set_is_idempotent_on_matching_value() {
2075 let (repo, _dir) = empty_repo();
2078 let cwd = repo.workdir().expect("workdir");
2079 config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("first");
2080 config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("second");
2081 assert_eq!(
2082 config_values(&repo, "lfs.standalonetransferagent"),
2083 vec!["git-lfs-object-store".to_owned()],
2084 );
2085 }
2086
2087 #[test]
2088 fn config_set_replaces_differing_value() {
2089 let (repo, _dir) = empty_repo();
2090 let cwd = repo.workdir().expect("workdir");
2091 config_set(cwd, "lfs.standalonetransferagent", "old-name").expect("first");
2092 config_set(cwd, "lfs.standalonetransferagent", "new-name").expect("second");
2093 assert_eq!(
2094 config_values(&repo, "lfs.standalonetransferagent"),
2095 vec!["new-name".to_owned()],
2096 );
2097 }
2098
2099 #[test]
2100 fn config_set_collapses_legacy_duplicates() {
2101 let (repo, _dir) = empty_repo();
2105 let cwd = repo.workdir().expect("workdir");
2106 config_add(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("seed 1");
2107 config_add(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("seed 2");
2108 assert_eq!(
2109 config_values(&repo, "lfs.standalonetransferagent").len(),
2110 2,
2111 "pre-condition: two duplicate entries",
2112 );
2113 config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("set");
2114 assert_eq!(
2115 config_values(&repo, "lfs.standalonetransferagent"),
2116 vec!["git-lfs-object-store".to_owned()],
2117 );
2118 }
2119
2120 #[test]
2121 fn config_set_idempotent_call_does_not_rewrite_file() {
2122 let (repo, _dir) = empty_repo();
2127 let cwd = repo.workdir().expect("workdir");
2128 config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("first");
2129 let after_first = read_local_config(&repo);
2130 config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("second");
2131 assert_eq!(read_local_config(&repo), after_first);
2132 }
2133
2134 #[test]
2135 fn config_set_many_writes_both_entries() {
2136 let (repo, _dir) = empty_repo();
2137 let entries: &[(&str, &str)] = &[
2138 (
2139 "lfs.customtransfer.git-lfs-object-store.path",
2140 "git-lfs-object-store",
2141 ),
2142 ("lfs.standalonetransferagent", "git-lfs-object-store"),
2143 ];
2144 config_set_many(repo.workdir().expect("workdir"), entries).expect("config_set_many");
2145 for (key, value) in entries {
2146 assert_eq!(config_values(&repo, key), vec![(*value).to_owned()]);
2147 }
2148 }
2149
2150 #[test]
2151 fn config_set_many_is_idempotent_across_all_entries() {
2152 let (repo, _dir) = empty_repo();
2155 let cwd = repo.workdir().expect("workdir");
2156 let entries: &[(&str, &str)] = &[
2157 (
2158 "lfs.customtransfer.git-lfs-object-store.path",
2159 "git-lfs-object-store",
2160 ),
2161 ("lfs.standalonetransferagent", "git-lfs-object-store"),
2162 ];
2163 config_set_many(cwd, entries).expect("first");
2164 config_set_many(cwd, entries).expect("second");
2165 for (key, value) in entries {
2166 assert_eq!(
2167 config_values(&repo, key),
2168 vec![(*value).to_owned()],
2169 "key {key:?} should have a single entry after two set_many calls",
2170 );
2171 }
2172 }
2173
2174 #[test]
2175 fn config_set_many_validates_all_entries_before_writing() {
2176 let (repo, _dir) = empty_repo();
2180 let cwd = repo.workdir().expect("workdir");
2181 let before = read_local_config(&repo);
2182 let err = config_set_many(
2183 cwd,
2184 &[
2185 ("lfs.standalonetransferagent", "git-lfs-object-store"),
2186 ("nodot", "v"),
2187 ],
2188 )
2189 .expect_err("expected parse failure on second entry");
2190 assert!(matches!(err, GitError::ConfigKeyParse(_)), "got {err:?}");
2191 assert_eq!(read_local_config(&repo), before);
2192 assert!(
2193 config_values(&repo, "lfs.standalonetransferagent").is_empty(),
2194 "first entry should not have been written",
2195 );
2196 }
2197
2198 #[test]
2199 fn config_set_many_empty_input_is_noop() {
2200 let (repo, _dir) = empty_repo();
2201 let cwd = repo.workdir().expect("workdir");
2202 let before = read_local_config(&repo);
2203 config_set_many(cwd, &[]).expect("noop");
2204 assert_eq!(read_local_config(&repo), before);
2205 }
2206
2207 #[test]
2208 fn config_unset_if_present_removes_existing_value() {
2209 let (repo, _dir) = empty_repo();
2210 let cwd = repo.workdir().expect("workdir");
2211 config_add(cwd, "lfs.customtransfer.git-lfs-object-store.args", "debug").expect("seed");
2212 config_unset_if_present(cwd, "lfs.customtransfer.git-lfs-object-store.args")
2213 .expect("unset");
2214 assert!(config_values(&repo, "lfs.customtransfer.git-lfs-object-store.args").is_empty(),);
2215 }
2216
2217 #[test]
2218 fn config_unset_if_present_succeeds_when_key_absent() {
2219 let (repo, _dir) = empty_repo();
2223 let cwd = repo.workdir().expect("workdir");
2224 config_unset_if_present(cwd, "lfs.never.set").expect("missing section is ok");
2226 config_add(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("seed");
2228 config_unset_if_present(cwd, "lfs.othervalue").expect("missing value is ok");
2229 assert_eq!(
2231 config_values(&repo, "lfs.standalonetransferagent"),
2232 vec!["git-lfs-object-store".to_owned()],
2233 );
2234 }
2235
2236 #[test]
2237 fn config_unset_if_present_propagates_non_keynotset_errors() {
2238 let (repo, _dir) = empty_repo();
2241 let err = config_unset_if_present(repo.workdir().expect("workdir"), "")
2242 .expect_err("expected parse error");
2243 assert!(matches!(err, GitError::ConfigKeyParse(_)), "got {err:?}");
2244 }
2245
2246 #[test]
2249 fn shallow_boundaries_depth_one_returns_tip() {
2250 let (repo, _dir) = empty_repo();
2254 let a = add_commit(&repo, "refs/heads/main", &[], "a");
2255 let b = add_commit(&repo, "refs/heads/main", &[a], "b");
2256 let tip = Sha::from_object_id(b);
2257 let bounds =
2258 shallow_boundaries(&repo, tip, NonZeroU32::new(1).unwrap()).expect("boundaries");
2259 assert_eq!(bounds, vec![b]);
2260 }
2261
2262 #[test]
2263 fn shallow_boundaries_returns_empty_when_history_shorter_than_depth() {
2264 let (repo, _dir) = empty_repo();
2267 let a = add_commit(&repo, "refs/heads/main", &[], "a");
2268 let tip = Sha::from_object_id(a);
2269 let bounds =
2270 shallow_boundaries(&repo, tip, NonZeroU32::new(5).unwrap()).expect("boundaries");
2271 assert!(bounds.is_empty(), "expected empty, got {bounds:?}");
2272 }
2273
2274 #[test]
2275 fn shallow_boundaries_at_merge_returns_frontier_at_depth() {
2276 let (repo, _dir) = empty_repo();
2287 let c = add_commit(&repo, "refs/heads/main", &[], "C");
2288 let a = add_commit(&repo, "refs/heads/main", &[c], "A");
2289 let b = add_commit(&repo, "refs/heads/side", &[c], "B");
2290 let m = add_commit(&repo, "refs/heads/main", &[a, b], "M");
2291 let tip = Sha::from_object_id(m);
2292 let bounds =
2293 shallow_boundaries(&repo, tip, NonZeroU32::new(2).unwrap()).expect("boundaries");
2294 let mut sorted = bounds.clone();
2295 sorted.sort_unstable();
2296 let mut expected = vec![a, b];
2297 expected.sort_unstable();
2298 assert_eq!(sorted, expected);
2299 }
2300
2301 #[test]
2302 fn shallow_boundaries_at_merge_with_depth_one_returns_tip() {
2303 let (repo, _dir) = empty_repo();
2307 let a = add_commit(&repo, "refs/heads/main", &[], "A");
2308 let b = add_commit(&repo, "refs/heads/side", &[], "B");
2309 let m = add_commit(&repo, "refs/heads/main", &[a, b], "M");
2310 let tip = Sha::from_object_id(m);
2311 let bounds =
2312 shallow_boundaries(&repo, tip, NonZeroU32::new(1).unwrap()).expect("boundaries");
2313 assert_eq!(bounds, vec![m]);
2314 }
2315
2316 #[test]
2317 fn write_shallow_file_writes_boundaries_when_absent() {
2318 let (repo, dir) = empty_repo();
2319 let a = add_commit(&repo, "refs/heads/main", &[], "a");
2320 write_shallow_file(dir.path(), &[a]).expect("write");
2321 let path = repo.git_dir().join("shallow");
2322 let contents = std::fs::read_to_string(&path).expect("read shallow");
2323 assert_eq!(contents, format!("{a}\n"));
2324 }
2325
2326 #[test]
2327 fn write_shallow_file_dedupes_entries() {
2328 let (repo, dir) = empty_repo();
2334 let a = add_commit(&repo, "refs/heads/main", &[], "a");
2335 let path = repo.git_dir().join("shallow");
2336 std::fs::write(&path, format!("{a}\n")).expect("seed");
2337 write_shallow_file(dir.path(), &[a]).expect("write");
2338 let contents = std::fs::read_to_string(&path).expect("read");
2339 assert_eq!(contents, format!("{a}\n"));
2340 }
2341
2342 #[test]
2343 fn write_shallow_file_no_boundaries_no_existing_does_not_create_file() {
2344 let (repo, dir) = empty_repo();
2347 let path = repo.git_dir().join("shallow");
2348 write_shallow_file(dir.path(), &[]).expect("noop");
2349 assert!(!path.exists(), "shallow file unexpectedly created");
2350 }
2351
2352 #[test]
2353 fn write_shallow_file_prunes_existing_when_parents_in_odb() {
2354 let (repo, dir) = empty_repo();
2360 let a = add_commit(&repo, "refs/heads/main", &[], "a");
2361 let b = add_commit(&repo, "refs/heads/main", &[a], "b");
2362 let path = repo.git_dir().join("shallow");
2363 std::fs::write(&path, format!("{b}\n")).expect("seed depth-1 tip");
2364 write_shallow_file(dir.path(), &[a]).expect("deepen");
2365 let contents = std::fs::read_to_string(&path).expect("read");
2366 assert_eq!(contents, format!("{a}\n"));
2367 }
2368
2369 #[test]
2370 fn write_shallow_file_unlinks_when_set_becomes_empty_after_pruning() {
2371 let (repo, dir) = empty_repo();
2376 let a = add_commit(&repo, "refs/heads/main", &[], "a");
2377 let b = add_commit(&repo, "refs/heads/main", &[a], "b");
2378 let path = repo.git_dir().join("shallow");
2379 std::fs::write(&path, format!("{b}\n")).expect("seed");
2380 write_shallow_file(dir.path(), &[]).expect("deepen-to-full");
2381 assert!(!path.exists(), "shallow file should be unlinked");
2382 }
2383
2384 #[test]
2385 fn write_shallow_file_drops_existing_root_commit() {
2386 let (repo, dir) = empty_repo();
2391 let a = add_commit(&repo, "refs/heads/main", &[], "a");
2392 let b = add_commit(&repo, "refs/heads/main", &[a], "b");
2393 let path = repo.git_dir().join("shallow");
2394 std::fs::write(&path, format!("{a}\n")).expect("seed");
2395 write_shallow_file(dir.path(), &[b]).expect("write");
2396 let contents = std::fs::read_to_string(&path).expect("read");
2397 assert_eq!(contents, format!("{b}\n"));
2398 }
2399
2400 #[test]
2401 fn write_shallow_file_unlinks_when_only_existing_was_root() {
2402 let (repo, dir) = empty_repo();
2403 let a = add_commit(&repo, "refs/heads/main", &[], "a");
2404 let path = repo.git_dir().join("shallow");
2405 std::fs::write(&path, format!("{a}\n")).expect("seed");
2406 write_shallow_file(dir.path(), &[]).expect("write");
2407 assert!(!path.exists(), "shallow file should be unlinked");
2408 }
2409
2410 #[test]
2411 fn write_shallow_file_keeps_existing_when_a_parent_is_missing() {
2412 let (repo, dir) = empty_repo();
2417 let synthetic_parent =
2418 ObjectId::from_hex(b"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").expect("synthetic OID");
2419 let orphan = commit_with_synthetic_parents(&repo, &[synthetic_parent], "orphan");
2420 let new_root = add_commit(&repo, "refs/heads/main", &[], "new_root");
2421 let path = repo.git_dir().join("shallow");
2422 std::fs::write(&path, format!("{orphan}\n")).expect("seed");
2423 write_shallow_file(dir.path(), &[new_root]).expect("write");
2424 let contents = std::fs::read_to_string(&path).expect("read");
2425 let mut expected = [format!("{orphan}"), format!("{new_root}")];
2426 expected.sort();
2427 assert_eq!(contents.trim(), expected.join("\n"));
2428 }
2429
2430 #[test]
2431 fn write_shallow_file_keeps_octopus_merge_when_any_parent_missing() {
2432 let (repo, dir) = empty_repo();
2437 let p1 = add_commit(&repo, "refs/heads/p1", &[], "p1");
2438 let p2 = add_commit(&repo, "refs/heads/p2", &[], "p2");
2439 let synthetic =
2440 ObjectId::from_hex(b"cafef00dcafef00dcafef00dcafef00dcafef00d").expect("synthetic");
2441 let merge = commit_with_synthetic_parents(&repo, &[p1, p2, synthetic], "octopus");
2442 let path = repo.git_dir().join("shallow");
2443 std::fs::write(&path, format!("{merge}\n")).expect("seed");
2444 write_shallow_file(dir.path(), &[]).expect("write");
2445 let contents = std::fs::read_to_string(&path).expect("read");
2446 assert_eq!(contents, format!("{merge}\n"));
2447 }
2448
2449 #[test]
2450 fn write_shallow_file_drops_entry_pointing_at_non_commit() {
2451 let (repo, dir) = empty_repo();
2454 let tree_id = make_marker_tree(&repo);
2455 let path = repo.git_dir().join("shallow");
2456 std::fs::write(&path, format!("{tree_id}\n")).expect("seed");
2457 write_shallow_file(dir.path(), &[]).expect("write");
2458 assert!(!path.exists(), "stale tree entry should not preserve file");
2459 }
2460
2461 #[test]
2462 fn write_shallow_file_drops_entry_missing_from_odb() {
2463 let (repo, dir) = empty_repo();
2464 let synthetic =
2465 ObjectId::from_hex(b"abcdef0123456789abcdef0123456789abcdef01").expect("synthetic");
2466 let path = repo.git_dir().join("shallow");
2467 std::fs::write(&path, format!("{synthetic}\n")).expect("seed");
2468 write_shallow_file(dir.path(), &[]).expect("write");
2469 assert!(!path.exists(), "missing-OID entry should not preserve file");
2470 }
2471
2472 #[tokio::test]
2475 async fn bundle_unbundle_round_trips_natively() {
2476 let (src_repo, src_dir) = empty_repo();
2477 let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
2478 let sha = Sha::from_object_id(oid);
2479 let ref_name = RefName::new("refs/heads/main").expect("RefName");
2480
2481 let bundles = TempDir::new().expect("tempdir");
2482 let bundle_path = bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
2483 .await
2484 .expect("bundle");
2485 assert!(bundle_path.exists(), "bundle not written");
2486
2487 let first_line = {
2489 use std::io::BufRead as _;
2490 let f = std::fs::File::open(&bundle_path).expect("open bundle");
2491 let mut buf = String::new();
2492 std::io::BufReader::new(f)
2493 .read_line(&mut buf)
2494 .expect("read");
2495 buf.trim_end().to_owned()
2496 };
2497 assert_eq!(first_line, "# v2 git bundle", "bundle magic mismatch");
2498
2499 let (dst_repo, _dst_dir) = empty_repo();
2500 unbundle(&dst_repo, bundles.path(), sha)
2501 .await
2502 .expect("unbundle");
2503 assert!(
2507 dst_repo
2508 .objects
2509 .clone()
2510 .into_inner()
2511 .contains(sha.as_object_id()),
2512 "commit object not in dst ODB after unbundle"
2513 );
2514 branch::resolve(&dst_repo, &sha.to_string()).expect("resolve must work on bundled OID");
2519
2520 let pack_dir = dst_repo.git_dir().join("objects/pack");
2525 let keep_files: Vec<_> = std::fs::read_dir(&pack_dir)
2526 .expect("read pack dir")
2527 .filter_map(Result::ok)
2528 .filter(|e| e.path().extension().is_some_and(|x| x == "keep"))
2529 .collect();
2530 assert!(
2531 keep_files.is_empty(),
2532 ".keep files not removed after unbundle: {keep_files:?}"
2533 );
2534 drop(src_dir);
2535 }
2536
2537 #[tokio::test]
2538 async fn bundle_includes_full_commit_history() {
2539 let (src_repo, src_dir) = empty_repo();
2540 let oid1 = add_commit(&src_repo, "refs/heads/main", &[], "first");
2541 let oid2 = add_commit(&src_repo, "refs/heads/main", &[oid1], "second");
2542 let sha = Sha::from_object_id(oid2);
2543 let ref_name = RefName::new("refs/heads/main").expect("RefName");
2544
2545 let bundles = TempDir::new().expect("tempdir");
2546 bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
2547 .await
2548 .expect("bundle");
2549
2550 let (dst_repo, _dst_dir) = empty_repo();
2551 unbundle(&dst_repo, bundles.path(), sha)
2552 .await
2553 .expect("unbundle");
2554
2555 let dst_odb = dst_repo.objects.clone().into_inner();
2557 assert!(
2558 dst_odb.contains(&oid1),
2559 "ancestor commit not in dst ODB after unbundle"
2560 );
2561 assert!(
2562 dst_odb.contains(&oid2),
2563 "tip commit not in dst ODB after unbundle"
2564 );
2565
2566 let blob_id = src_repo.write_blob(b"hello\n").expect("blob id").detach();
2571 assert!(
2572 dst_odb.contains(&blob_id),
2573 "blob object not in dst ODB — ObjectExpansion::TreeContents may not be working"
2574 );
2575 drop(src_dir);
2576 }
2577
2578 #[tokio::test]
2588 async fn unbundle_is_idempotent_on_duplicate_install() {
2589 let (src_repo, src_dir) = empty_repo();
2590 let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
2591 let sha = Sha::from_object_id(oid);
2592 let ref_name = RefName::new("refs/heads/main").expect("RefName");
2593
2594 let bundles = TempDir::new().expect("tempdir");
2595 bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
2596 .await
2597 .expect("bundle");
2598
2599 let (dst_repo, _dst_dir) = empty_repo();
2600
2601 unbundle(&dst_repo, bundles.path(), sha)
2602 .await
2603 .expect("first unbundle");
2604
2605 unbundle(&dst_repo, bundles.path(), sha)
2608 .await
2609 .expect("second unbundle (duplicate install)");
2610
2611 let pack_dir = dst_repo.git_dir().join("objects/pack");
2612 let keep_files: Vec<_> = std::fs::read_dir(&pack_dir)
2613 .expect("read pack dir")
2614 .filter_map(Result::ok)
2615 .filter(|e| e.path().extension().is_some_and(|x| x == "keep"))
2616 .collect();
2617 assert!(
2618 keep_files.is_empty(),
2619 ".keep files after duplicate unbundle: {keep_files:?}"
2620 );
2621
2622 assert!(
2623 dst_repo.objects.clone().into_inner().contains(&oid),
2624 "commit not in dst ODB after duplicate unbundle"
2625 );
2626 drop(src_dir);
2627 }
2628
2629 #[tokio::test]
2641 async fn concurrent_unbundle_same_sha_is_idempotent() {
2642 let (src_repo, src_dir) = empty_repo();
2643 let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
2644 let sha = Sha::from_object_id(oid);
2645 let ref_name = RefName::new("refs/heads/main").expect("RefName");
2646
2647 let bundles = TempDir::new().expect("tempdir");
2648 bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
2649 .await
2650 .expect("bundle");
2651
2652 let (dst_repo, _dst_dir) = empty_repo();
2653 let dst_cwd = repo_cwd(&dst_repo).to_owned();
2654 let bundles_path = bundles.path().to_owned();
2655
2656 let (r1, r2) = tokio::join!(
2657 unbundle_at(&dst_cwd, &bundles_path, sha),
2658 unbundle_at(&dst_cwd, &bundles_path, sha),
2659 );
2660 assert!(r1.is_ok(), "first concurrent unbundle failed: {r1:?}");
2661 assert!(r2.is_ok(), "second concurrent unbundle failed: {r2:?}");
2662
2663 let pack_dir = dst_repo.git_dir().join("objects/pack");
2665 let keep_files: Vec<_> = std::fs::read_dir(&pack_dir)
2666 .expect("read pack dir")
2667 .filter_map(Result::ok)
2668 .filter(|e| e.path().extension().is_some_and(|x| x == "keep"))
2669 .collect();
2670 assert!(
2671 keep_files.is_empty(),
2672 ".keep files lingered after concurrent unbundle: {keep_files:?}"
2673 );
2674
2675 assert!(
2676 dst_repo.objects.clone().into_inner().contains(&oid),
2677 "commit not in dst ODB after concurrent unbundle"
2678 );
2679 drop(src_dir);
2680 }
2681
2682 #[tokio::test]
2687 async fn git_bundle_create_readable_by_native_unbundle() {
2688 if !git_available() {
2689 eprintln!("skipping: git not on PATH");
2690 return;
2691 }
2692 let (src_repo, src_dir) = empty_repo();
2693 let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
2694 let sha = Sha::from_object_id(oid);
2695
2696 let bundles = TempDir::new().expect("tempdir");
2697 let bundle_path = bundles.path().join(format!("{sha}.bundle"));
2698
2699 let output = std::process::Command::new("git")
2700 .args(["bundle", "create"])
2701 .arg(&bundle_path)
2702 .arg("refs/heads/main")
2703 .current_dir(src_dir.path())
2704 .output()
2705 .expect("git bundle create");
2706 assert!(
2707 output.status.success(),
2708 "git bundle create failed:\n{}",
2709 String::from_utf8_lossy(&output.stderr)
2710 );
2711
2712 let (dst_repo, _dst_dir) = empty_repo();
2713 unbundle(&dst_repo, bundles.path(), sha)
2714 .await
2715 .expect("native unbundle of git-created bundle");
2716
2717 assert!(
2718 dst_repo.objects.clone().into_inner().contains(&oid),
2719 "commit not in dst ODB after native unbundle of git-created bundle"
2720 );
2721 drop(src_dir);
2722 }
2723
2724 #[tokio::test]
2728 async fn native_bundle_create_accepted_by_git() {
2729 if !git_available() {
2730 eprintln!("skipping: git not on PATH");
2731 return;
2732 }
2733 let (src_repo, src_dir) = empty_repo();
2734 let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
2735 let sha = Sha::from_object_id(oid);
2736 let ref_name = RefName::new("refs/heads/main").expect("RefName");
2737
2738 let bundles = TempDir::new().expect("tempdir");
2739 let bundle_path = bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
2740 .await
2741 .expect("native bundle");
2742 drop(src_repo);
2743
2744 let output = std::process::Command::new("git")
2746 .args(["bundle", "verify"])
2747 .arg(&bundle_path)
2748 .current_dir(src_dir.path())
2749 .output()
2750 .expect("git bundle verify");
2751 assert!(
2752 output.status.success(),
2753 "git bundle verify rejected our bundle:\n{}",
2754 String::from_utf8_lossy(&output.stderr)
2755 );
2756
2757 let (dst_repo, dst_dir) = empty_repo();
2759 let output = std::process::Command::new("git")
2760 .args(["bundle", "unbundle"])
2761 .arg(&bundle_path)
2762 .current_dir(dst_dir.path())
2763 .output()
2764 .expect("git bundle unbundle");
2765 assert!(
2766 output.status.success(),
2767 "git bundle unbundle failed on native bundle:\n{}",
2768 String::from_utf8_lossy(&output.stderr)
2769 );
2770
2771 assert!(
2772 dst_repo.objects.clone().into_inner().contains(&oid),
2773 "commit not in dst ODB after git bundle unbundle of native bundle"
2774 );
2775 drop(src_dir);
2776 }
2777}