1mod capabilities;
44mod config_edit;
45mod diff;
46mod index_io;
47mod notes_repo;
48mod objects;
49mod pack_plan;
50mod refs;
51mod remote_edit;
52mod rev_graph;
53mod status_plan;
54
55#[cfg(feature = "remote")]
56pub mod remote;
57
58use std::path::{Path, PathBuf};
59use std::sync::Arc;
60
61use sley_object::{Commit, EncodedObject, ObjectType, Tag, Tree, TreeBuilder};
62use sley_odb::{FileObjectDatabase, ObjectReader, ObjectWriter, install_reachable_pack};
63use sley_refs::{FileRefStore, RefTarget};
64use sley_rev::ResolvedTreePath;
65use sley_sequencer::create_annotated_tag;
66
67pub mod notes {
69 pub use sley_notes::*;
70}
71
72pub mod plumbing {
77 pub use sley_config;
78 pub use sley_core;
79 pub use sley_diff_format;
80 pub use sley_diff_merge;
81 pub use sley_formats;
82 pub use sley_grep;
83 pub use sley_index;
84 pub use sley_notes;
85 pub use sley_object;
86 pub use sley_odb;
87 pub use sley_pack;
88 pub use sley_pretty;
89 pub use sley_refs;
90 #[cfg(feature = "remote")]
91 pub use sley_remote;
92 pub use sley_rev;
93 pub use sley_sequencer;
94 pub use sley_worktree;
95}
96
97pub use sley_config::GitConfig;
100pub use sley_core::{
101 BString, FullName, GitError, GitTime, MissingObjectContext, MissingObjectKind, NotFoundKind,
102 ObjectFormat, ObjectId, Result, Signature,
103};
104pub use sley_diff_format as diff_format;
105pub use sley_diff_merge::{DiffNameStatusOptions, NameStatusEntry};
106pub use sley_grep as grep;
107pub use sley_index::{Index, IndexEntry, Stage as IndexStage};
108pub use sley_object::{
109 Commit as CommitObject, ObjectType as GitObjectType, Tag as TagObject, Tree as TreeObject,
110};
111pub use sley_object::{EntryKind, TreeBuilder as TreeEditor};
112pub use sley_odb::FileObjectDatabase as ObjectDatabase;
113pub use sley_pack::PackWriteOptions;
114pub use sley_pretty as pretty;
115pub use sley_refs::{
116 FileRefStore as RefStore, RefDeleteError, RefPrecondition, RefTarget as ReferenceTarget,
117};
118pub use sley_sequencer::TagCreate;
119pub use sley_worktree::{
120 AtomicMetadataWriteOptions, AtomicMetadataWriteResult, IndexStatProbe, IndexStatProbeCache,
121 ShortStatusEntry, ShortStatusOptions, ShortStatusRow, StatusIgnoredMode, StatusUntrackedMode,
122 StreamControl, SubmoduleStatus, WorktreeEntryState, write_metadata_file_atomic,
123};
124
125pub use capabilities::RepositoryCapabilities;
126pub use config_edit::{
127 ConfigEdit, ConfigEditError, ConfigEditPlan, ConfigEditScope, ConfigRemote, ConfigSectionEntry,
128 ConfigSectionId, ConfigSnapshot, ConfigSource, ConfigStackOptions, ConfigStackView,
129 ConfigValue, RemoteConfig, RemoteConfigRefusal, RemoteConfigRemove, RemoteConfigSet,
130 RemoteConfigSnapshot, RemoteConfigSource, RemoteConfigValue, WorktreeConfig,
131};
132pub use index_io::{IndexError, IndexWriteError, IndexWriteOptions, IndexWriteResult};
133pub use objects::{BlobFetchOptions, BlobStore, LoadedObject};
134pub use pack_plan::{
135 PreparedReachablePack, PreparedReachablePackFile, ReachablePackPlan, ReachablePackPlanBuilder,
136 ReachablePackSummary,
137};
138pub use refs::{
139 DeleteRef, HeadUpdateOptions, RefBatchChange, RefChange, RefChangeResult, RefConflict,
140 RefDeleteExpected, RefUpdateOptions, ReflogMessage,
141};
142pub use rev_graph::{ReachableCommit, ReachableCommitOptions, RevGraph};
143pub use status_plan::{
144 OwnedStatusRow, StatusCacheKey, StatusCode, StatusPlan, StatusPlanBuilder, StatusRow,
145};
146
147#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct Reference {
154 pub name: FullName,
156 pub target: RefTarget,
158}
159
160impl Reference {
161 pub fn direct_target(&self) -> Result<ObjectId> {
168 match &self.target {
169 RefTarget::Direct(oid) => Ok(*oid),
170 RefTarget::Symbolic(target) => Err(GitError::InvalidFormat(format!(
171 "reference {} is symbolic to {target}",
172 self.name
173 ))),
174 }
175 }
176
177 pub fn immediate_target(&self) -> &RefTarget {
179 &self.target
180 }
181
182 pub fn peeled_oid(&self, repo: &Repository) -> Result<Option<ObjectId>> {
185 match &self.target {
186 RefTarget::Direct(oid) => Ok(Some(*oid)),
187 RefTarget::Symbolic(name) => repo.resolve_symbolic(name),
188 }
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct Head {
199 pub symbolic_target: Option<FullName>,
202 pub oid: Option<ObjectId>,
204}
205
206impl Head {
207 pub fn is_unborn(&self) -> bool {
209 self.symbolic_target.is_some() && self.oid.is_none()
210 }
211
212 pub fn is_detached(&self) -> bool {
214 self.symbolic_target.is_none() && self.oid.is_some()
215 }
216
217 pub fn branch_name(&self) -> Option<&str> {
220 self.symbolic_target
221 .as_ref()
222 .map(FullName::as_str)
223 .and_then(|name| name.strip_prefix("refs/heads/"))
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum HeadState {
235 Missing,
237 Unborn {
239 target: FullName,
240 raw_target: String,
241 },
242 Attached {
244 target: FullName,
245 raw_target: String,
246 oid: ObjectId,
247 },
248 Detached { oid: ObjectId },
250}
251
252impl HeadState {
253 pub fn immediate_target(&self) -> Option<RefTarget> {
255 match self {
256 Self::Missing => None,
257 Self::Unborn { raw_target, .. } | Self::Attached { raw_target, .. } => {
258 Some(RefTarget::Symbolic(raw_target.clone()))
259 }
260 Self::Detached { oid } => Some(RefTarget::Direct(*oid)),
261 }
262 }
263
264 pub fn symbolic_target(&self) -> Option<&FullName> {
266 match self {
267 Self::Unborn { target, .. } | Self::Attached { target, .. } => Some(target),
268 Self::Missing | Self::Detached { .. } => None,
269 }
270 }
271
272 pub fn oid(&self) -> Option<ObjectId> {
275 match self {
276 Self::Attached { oid, .. } | Self::Detached { oid } => Some(*oid),
277 Self::Missing | Self::Unborn { .. } => None,
278 }
279 }
280
281 pub fn is_missing(&self) -> bool {
282 matches!(self, Self::Missing)
283 }
284
285 pub fn is_unborn(&self) -> bool {
286 matches!(self, Self::Unborn { .. })
287 }
288
289 pub fn is_attached(&self) -> bool {
290 matches!(self, Self::Attached { .. })
291 }
292
293 pub fn is_detached(&self) -> bool {
294 matches!(self, Self::Detached { .. })
295 }
296
297 pub fn branch_name(&self) -> Option<&str> {
299 self.symbolic_target()
300 .map(FullName::as_str)
301 .and_then(|target| target.strip_prefix("refs/heads/"))
302 }
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub struct OpenOptions {
309 exact_path: bool,
310 bare: bool,
311}
312
313impl OpenOptions {
314 pub fn new() -> Self {
315 Self {
316 exact_path: false,
317 bare: false,
318 }
319 }
320
321 pub fn exact_path(mut self, exact_path: bool) -> Self {
324 self.exact_path = exact_path;
325 self
326 }
327
328 pub fn bare(mut self, bare: bool) -> Self {
330 self.bare = bare;
331 self
332 }
333
334 pub fn is_exact_path(self) -> bool {
335 self.exact_path
336 }
337
338 pub fn requires_bare(self) -> bool {
339 self.bare
340 }
341}
342
343impl Default for OpenOptions {
344 fn default() -> Self {
345 Self::new()
346 }
347}
348
349#[derive(Debug, Clone)]
358pub struct Repository {
359 git_dir: PathBuf,
360 common_dir: PathBuf,
361 format: ObjectFormat,
362 objects: Arc<FileObjectDatabase>,
363}
364
365impl PartialEq for Repository {
366 fn eq(&self, other: &Self) -> bool {
367 self.git_dir == other.git_dir
368 && self.common_dir == other.common_dir
369 && self.format == other.format
370 }
371}
372
373impl Eq for Repository {}
374
375impl Repository {
376 pub fn open(git_dir: impl AsRef<Path>) -> Result<Self> {
385 let git_dir = resolve_git_dir(git_dir.as_ref())?;
386 if !is_git_dir(&git_dir) {
387 return Err(GitError::repository_not_found(format!(
388 "not a git repository: {}",
389 git_dir.display()
390 )));
391 }
392 Self::from_git_dir(git_dir)
393 }
394
395 pub fn open_with(path: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
400 let repo = if options.exact_path {
401 Self::open(path)
402 } else {
403 Self::discover(path)
404 }?;
405 if options.bare && repo.workdir().is_some() {
406 return Err(GitError::InvalidFormat(format!(
407 "repository is not bare: {}",
408 repo.git_dir.display()
409 )));
410 }
411 Ok(repo)
412 }
413
414 pub fn open_exact_bare(git_dir: impl AsRef<Path>) -> Result<Self> {
416 Self::open_with(git_dir, OpenOptions::new().exact_path(true).bare(true))
417 }
418
419 pub fn discover(path: impl AsRef<Path>) -> Result<Self> {
423 let git_dir = discover_git_dir(path.as_ref())?;
424 Self::from_git_dir(git_dir)
425 }
426
427 pub fn init(path: impl AsRef<Path>) -> Result<Self> {
431 Self::init_with_format(path, ObjectFormat::Sha1, false)
432 }
433
434 pub fn init_bare(path: impl AsRef<Path>) -> Result<Self> {
437 Self::init_with_format(path, ObjectFormat::Sha1, true)
438 }
439
440 pub fn init_with_format(
443 path: impl AsRef<Path>,
444 format: ObjectFormat,
445 bare: bool,
446 ) -> Result<Self> {
447 let layout = sley_formats::RepositoryLayout::init_at(path, format, bare)?;
448 Self::from_git_dir(layout.git_dir)
449 }
450
451 fn from_git_dir(git_dir: PathBuf) -> Result<Self> {
452 let common_dir = sley_odb::repository_common_dir(&git_dir);
453 let format = read_object_format(&common_dir)?;
454 let objects = Arc::new(FileObjectDatabase::from_git_dir(&common_dir, format));
455 Ok(Self {
456 git_dir,
457 common_dir,
458 format,
459 objects,
460 })
461 }
462
463 pub fn git_dir(&self) -> &Path {
466 &self.git_dir
467 }
468
469 pub fn common_dir(&self) -> &Path {
473 &self.common_dir
474 }
475
476 pub fn workdir(&self) -> Option<PathBuf> {
486 sley_worktree::worktree_root_for_git_dir(&self.git_dir)
487 .ok()
488 .flatten()
489 }
490
491 pub fn is_shallow(&self) -> bool {
494 sley_worktree::is_shallow_repository(&self.git_dir)
495 }
496
497 pub fn short_status(&self) -> Result<Vec<ShortStatusEntry>> {
503 self.short_status_with_options(ShortStatusOptions::default())
504 }
505
506 pub fn stream_short_status<F>(&self, emit: F) -> Result<()>
512 where
513 F: for<'a> FnMut(ShortStatusRow<'a>) -> Result<StreamControl>,
514 {
515 self.stream_short_status_with_options(ShortStatusOptions::default(), emit)
516 }
517
518 pub fn short_status_with_options(
522 &self,
523 options: ShortStatusOptions,
524 ) -> Result<Vec<ShortStatusEntry>> {
525 let mut entries = Vec::new();
526 self.stream_short_status_with_options(options, |entry| {
527 entries.push(entry.to_owned_entry());
528 Ok(StreamControl::Continue)
529 })?;
530 Ok(entries)
531 }
532
533 pub fn stream_short_status_with_options<F>(
535 &self,
536 options: ShortStatusOptions,
537 emit: F,
538 ) -> Result<()>
539 where
540 F: for<'a> FnMut(ShortStatusRow<'a>) -> Result<StreamControl>,
541 {
542 let workdir = self.workdir().ok_or_else(|| {
543 GitError::Unsupported("short status requires a repository worktree".into())
544 })?;
545 sley_worktree::stream_short_status_with_options(
546 &workdir,
547 &self.git_dir,
548 self.format,
549 options,
550 emit,
551 )
552 }
553
554 pub fn short_status_count_with_options(&self, options: ShortStatusOptions) -> Result<usize> {
556 let workdir = self.workdir().ok_or_else(|| {
557 GitError::Unsupported("short status requires a repository worktree".into())
558 })?;
559 sley_worktree::short_status_count_with_options(
560 &workdir,
561 &self.git_dir,
562 self.format,
563 options,
564 )
565 }
566
567 pub fn worktree_entry_state(
570 &self,
571 path: impl AsRef<Path>,
572 expected_oid: &ObjectId,
573 expected_mode: u32,
574 index_probe: Option<&IndexStatProbe>,
575 ) -> Result<WorktreeEntryState> {
576 let workdir = self.workdir().ok_or_else(|| {
577 GitError::Unsupported("worktree entry state requires a repository worktree".into())
578 })?;
579 sley_worktree::worktree_entry_state(
580 &workdir,
581 &self.git_dir,
582 self.format,
583 path,
584 expected_oid,
585 expected_mode,
586 index_probe,
587 )
588 }
589
590 pub fn remote_names(&self) -> Result<Vec<String>> {
598 Ok(sley_config::remotes::remote_names(&self.config_snapshot()?))
599 }
600
601 pub fn object_format(&self) -> ObjectFormat {
604 self.format
605 }
606
607 pub fn references(&self) -> FileRefStore {
610 FileRefStore::new(self.git_dir.clone(), self.format)
611 }
612
613 pub fn config(&self) -> Result<GitConfig> {
618 let path = self.common_dir.join("config");
619 match GitConfig::read(&path) {
620 Ok(config) => Ok(config),
621 Err(GitError::Io(_)) | Err(GitError::NotFound(_)) => Ok(GitConfig::default()),
622 Err(err) => Err(err),
623 }
624 }
625
626 pub fn config_snapshot(&self) -> Result<GitConfig> {
639 let context = sley_config::ConfigIncludeContext::new(
640 Some(self.config_include_git_dir()),
641 self.config_include_branch(),
642 );
643 sley_config::load_effective_config(&self.common_dir, &context)
644 }
645
646 pub fn config_string(&self, section: &str, key: &str) -> Result<Option<String>> {
651 self.config_string_subsection(section, None, key)
652 }
653
654 pub fn config_string_subsection(
658 &self,
659 section: &str,
660 subsection: Option<&str>,
661 key: &str,
662 ) -> Result<Option<String>> {
663 let config = self.config_snapshot()?;
664 Ok(config.get(section, subsection, key).map(str::to_owned))
665 }
666
667 fn config_include_git_dir(&self) -> PathBuf {
671 std::fs::canonicalize(&self.common_dir).unwrap_or_else(|_| self.common_dir.clone())
672 }
673
674 fn config_include_branch(&self) -> Option<String> {
678 let head = self.head().ok()?;
679 head.symbolic_target
680 .as_ref()
681 .map(FullName::as_str)
682 .and_then(|target| target.strip_prefix("refs/heads/"))
683 .map(str::to_string)
684 }
685
686 pub fn head(&self) -> Result<Head> {
689 match self.head_state()? {
690 HeadState::Missing => Err(GitError::reference_not_found("HEAD is missing")),
691 HeadState::Detached { oid } => Ok(Head {
692 symbolic_target: None,
693 oid: Some(oid),
694 }),
695 HeadState::Unborn { target, .. } => Ok(Head {
696 symbolic_target: Some(target),
697 oid: None,
698 }),
699 HeadState::Attached { target, oid, .. } => Ok(Head {
700 symbolic_target: Some(target),
701 oid: Some(oid),
702 }),
703 }
704 }
705
706 pub fn head_state(&self) -> Result<HeadState> {
709 match self.references().read_ref("HEAD")? {
710 None => Ok(HeadState::Missing),
711 Some(RefTarget::Direct(oid)) => Ok(HeadState::Detached { oid }),
712 Some(RefTarget::Symbolic(name)) => {
713 let target = FullName::new(&name)?;
714 match self.resolve_symbolic(&name)? {
715 Some(oid) => Ok(HeadState::Attached {
716 target,
717 raw_target: name,
718 oid,
719 }),
720 None => Ok(HeadState::Unborn {
721 target,
722 raw_target: name,
723 }),
724 }
725 }
726 }
727 }
728
729 pub fn find_reference(&self, name: &str) -> Result<Option<Reference>> {
732 let name = FullName::new(name)?;
733 let refs = self.references();
734 Ok(refs
735 .read_ref(name.as_str())?
736 .map(|target| Reference { name, target }))
737 }
738
739 pub fn reference_exists(&self, name: &str) -> Result<bool> {
742 self.references().raw_ref_exists(name)
743 }
744
745 pub fn require_reference(&self, name: &str) -> Result<Reference> {
747 self.find_reference(name)?
748 .ok_or_else(|| GitError::reference_not_found(name))
749 }
750
751 pub fn peel_to_object_oid(&self, oid: ObjectId) -> Result<ObjectId> {
757 const MAX_TAG_DEPTH: usize = 1024;
758 let mut current = oid;
759 for _ in 0..MAX_TAG_DEPTH {
760 let object = self.read_object(¤t).map_err(|err| {
761 expect_missing_object_kind(
762 err,
763 current,
764 MissingObjectKind::Object,
765 MissingObjectContext::Traversal,
766 )
767 })?;
768 if object.object_type != ObjectType::Tag {
769 return Ok(current);
770 }
771 let tag = Tag::parse(self.format, &object.body)?;
772 current = tag.object;
773 }
774 Err(GitError::InvalidObject(format!(
775 "annotated tag chain too deep starting at {oid}"
776 )))
777 }
778
779 pub fn peel_to_commit_oid(&self, oid: ObjectId) -> Result<ObjectId> {
781 sley_rev::peel_to_commit(self.objects.as_ref(), self.format, &oid).map_err(|err| {
782 expect_missing_object_kind(
783 err,
784 oid,
785 MissingObjectKind::Commit,
786 MissingObjectContext::Traversal,
787 )
788 })
789 }
790
791 pub fn rev_parse(&self, spec: &str) -> Result<ObjectId> {
795 sley_rev::resolve_revision(&self.git_dir, self.format, spec)
796 }
797
798 pub fn resolve_path(&self, rev: &str, path: &str) -> Result<ResolvedTreePath> {
804 sley_rev::resolve_rev_path_entry(
805 &self.git_dir,
806 self.format,
807 self.objects.as_ref(),
808 rev,
809 path,
810 )
811 }
812
813 pub fn write_annotated_tag(&self, tag: TagCreate) -> Result<ObjectId> {
818 let mut objects = self.objects_mut();
819 create_annotated_tag(&mut objects, tag)
820 }
821
822 pub fn copy_reachable_from(&self, other: &Repository, roots: &[ObjectId]) -> Result<()> {
836 if self.format != other.format {
837 return Err(GitError::InvalidObjectId(format!(
838 "object format mismatch: destination uses {}, source uses {}",
839 self.format.name(),
840 other.format.name()
841 )));
842 }
843 install_reachable_pack(
844 other.objects().as_ref(),
845 self.objects().as_ref(),
846 self.format,
847 roots.iter().copied(),
848 )?;
849 self.refresh_objects();
850 Ok(())
851 }
852
853 pub fn read_object(&self, oid: &ObjectId) -> Result<Arc<EncodedObject>> {
855 ObjectReader::read_object(self.objects.as_ref(), oid)
856 }
857
858 pub fn read_commit(&self, oid: &ObjectId) -> Result<Commit> {
861 let object = self.read_object(oid).map_err(|err| {
862 expect_missing_object_kind(
863 err,
864 *oid,
865 MissingObjectKind::Commit,
866 MissingObjectContext::Read,
867 )
868 })?;
869 if object.object_type != ObjectType::Commit {
870 return Err(GitError::InvalidObject(format!(
871 "object {oid} is a {}, not a commit",
872 object.object_type.as_str()
873 )));
874 }
875 Commit::parse(self.format, &object.body)
876 }
877
878 pub fn read_tree(&self, oid: &ObjectId) -> Result<Tree> {
881 let object = self.read_object(oid).map_err(|err| {
882 expect_missing_object_kind(
883 err,
884 *oid,
885 MissingObjectKind::Tree,
886 MissingObjectContext::Read,
887 )
888 })?;
889 if object.object_type != ObjectType::Tree {
890 return Err(GitError::InvalidObject(format!(
891 "object {oid} is a {}, not a tree",
892 object.object_type.as_str()
893 )));
894 }
895 Tree::parse(self.format, &object.body)
896 }
897
898 pub fn read_tag(&self, oid: &ObjectId) -> Result<Tag> {
901 let object = self.read_object(oid).map_err(|err| {
902 expect_missing_object_kind(
903 err,
904 *oid,
905 MissingObjectKind::Tag,
906 MissingObjectContext::Read,
907 )
908 })?;
909 if object.object_type != ObjectType::Tag {
910 return Err(GitError::InvalidObject(format!(
911 "object {oid} is a {}, not a tag",
912 object.object_type.as_str()
913 )));
914 }
915 Tag::parse(self.format, &object.body)
916 }
917
918 pub fn read_commit_author(&self, oid: &ObjectId) -> Result<Option<Signature>> {
925 Ok(self.read_commit(oid)?.author_signature())
926 }
927
928 pub fn read_commit_committer(&self, oid: &ObjectId) -> Result<Option<Signature>> {
932 Ok(self.read_commit(oid)?.committer_signature())
933 }
934
935 pub fn read_tag_tagger(&self, oid: &ObjectId) -> Result<Option<Signature>> {
940 Ok(self.read_tag(oid)?.tagger_signature())
941 }
942
943 pub fn write_object(&self, object: EncodedObject) -> Result<ObjectId> {
947 let odb = self.objects_mut();
948 odb.write_object(object)
949 }
950
951 pub fn write_raw_object(
954 &self,
955 object_type: ObjectType,
956 body: impl Into<Vec<u8>>,
957 ) -> Result<ObjectId> {
958 self.write_object(EncodedObject::new(object_type, body))
959 }
960
961 pub fn write_blob(&self, bytes: impl Into<Vec<u8>>) -> Result<ObjectId> {
963 self.write_object(EncodedObject::new(ObjectType::Blob, bytes))
964 }
965
966 pub fn edit_tree(&self, base: &ObjectId) -> Result<TreeBuilder> {
971 if base.is_null() || *base == ObjectId::empty_tree(self.format) {
972 return Ok(TreeBuilder::new());
973 }
974 Ok(TreeBuilder::from_tree(self.read_tree(base)?))
975 }
976
977 pub fn write_tree(&self, builder: TreeBuilder) -> Result<ObjectId> {
980 self.write_object(EncodedObject::new(ObjectType::Tree, builder.write()))
981 }
982
983 pub fn open_index(&self) -> Result<Option<Index>> {
986 sley_worktree::read_repository_index(&self.git_dir, self.format)
987 }
988
989 pub fn index_from_tree(&self, tree_oid: &ObjectId) -> Result<Index> {
992 sley_worktree::index_from_tree(self.objects.as_ref(), self.format, tree_oid)
993 }
994
995 fn resolve_symbolic(&self, name: &str) -> Result<Option<ObjectId>> {
999 let refs = self.references();
1000 const MAX_SYMREF_DEPTH: usize = 5;
1003 let mut current = name.to_string();
1004 for _ in 0..MAX_SYMREF_DEPTH {
1005 match refs.read_ref(¤t)? {
1006 None => return Ok(None),
1007 Some(RefTarget::Direct(oid)) => return Ok(Some(oid)),
1008 Some(RefTarget::Symbolic(next)) => current = next,
1009 }
1010 }
1011 Err(GitError::InvalidFormat(format!(
1012 "symbolic reference chain too deep starting at {name}"
1013 )))
1014 }
1015}
1016
1017fn expect_missing_object_kind(
1018 err: GitError,
1019 oid: ObjectId,
1020 expected: MissingObjectKind,
1021 context: MissingObjectContext,
1022) -> GitError {
1023 match err.not_found_kind() {
1024 Some(NotFoundKind::Object { .. }) => {
1025 GitError::object_kind_not_found_in(oid, expected, context)
1026 }
1027 _ => err,
1028 }
1029}
1030
1031fn read_object_format(common_dir: &Path) -> Result<ObjectFormat> {
1034 let config_path = common_dir.join("config");
1035 match GitConfig::read(&config_path) {
1036 Ok(config) => config.repository_object_format(),
1037 Err(GitError::Io(_)) | Err(GitError::NotFound(_)) => Ok(ObjectFormat::Sha1),
1038 Err(err) => Err(err),
1039 }
1040}
1041
1042fn resolve_git_dir(path: &Path) -> Result<PathBuf> {
1045 if path.is_file()
1046 && let Some(target) = read_gitdir_link(path)?
1047 {
1048 return Ok(target);
1049 }
1050 Ok(path.to_path_buf())
1051}
1052
1053fn is_git_dir(path: &Path) -> bool {
1056 path.join("HEAD").is_file()
1057 && (path.join("objects").is_dir() || path.join("commondir").is_file())
1058}
1059
1060fn read_gitdir_link(path: &Path) -> Result<Option<PathBuf>> {
1063 let contents = std::fs::read_to_string(path)?;
1064 let Some(target) = contents.trim().strip_prefix("gitdir:") else {
1065 return Ok(None);
1066 };
1067 let target = PathBuf::from(target.trim());
1068 if target.is_absolute() {
1069 Ok(Some(target))
1070 } else {
1071 let base = path.parent().unwrap_or_else(|| Path::new(""));
1072 Ok(Some(base.join(target)))
1073 }
1074}
1075
1076fn discover_git_dir(start: &Path) -> Result<PathBuf> {
1079 let start = if start.as_os_str().is_empty() {
1080 Path::new(".")
1081 } else {
1082 start
1083 };
1084 let absolute = if start.is_absolute() {
1085 start.to_path_buf()
1086 } else {
1087 std::env::current_dir()?.join(start)
1088 };
1089 for candidate in absolute.ancestors() {
1090 let dot_git = candidate.join(".git");
1091 if dot_git.is_dir() {
1092 return Ok(dot_git);
1093 }
1094 if dot_git.is_file()
1095 && let Some(git_dir) = read_gitdir_link(&dot_git)?
1096 && is_git_dir(&git_dir)
1097 {
1098 return Ok(git_dir);
1099 }
1100 if is_git_dir(candidate) {
1101 return Ok(candidate.to_path_buf());
1102 }
1103 }
1104 Err(GitError::repository_not_found(format!(
1105 "not a git repository (or any parent up to {}): {}",
1106 absolute.display(),
1107 start.display()
1108 )))
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114 use sley_odb::ObjectWriter;
1115 use std::fs;
1116 use std::sync::atomic::{AtomicU64, Ordering};
1117
1118 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1119
1120 struct TempDir {
1122 path: PathBuf,
1123 }
1124
1125 impl TempDir {
1126 fn new() -> Self {
1127 let path = std::env::temp_dir().join(format!(
1128 "sley-facade-{}-{}",
1129 std::process::id(),
1130 TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1131 ));
1132 fs::create_dir_all(&path).expect("create temp dir");
1133 Self { path }
1134 }
1135
1136 fn path(&self) -> &Path {
1137 &self.path
1138 }
1139 }
1140
1141 impl Drop for TempDir {
1142 fn drop(&mut self) {
1143 let _ = fs::remove_dir_all(&self.path);
1144 }
1145 }
1146
1147 fn seed_commit(repo: &Repository) -> ObjectId {
1150 let db = repo.objects_mut();
1151
1152 let blob_oid = db
1153 .write_object(EncodedObject::new(ObjectType::Blob, b"hello\n".to_vec()))
1154 .expect("write blob");
1155
1156 let tree = Tree {
1157 entries: vec![sley_object::TreeEntry {
1158 mode: 0o100644,
1159 name: BString::from(b"hello.txt"),
1160 oid: blob_oid,
1161 }],
1162 };
1163 let tree_oid = db
1164 .write_object(EncodedObject::new(ObjectType::Tree, tree.write()))
1165 .expect("write tree");
1166
1167 let commit = Commit {
1168 tree: tree_oid,
1169 parents: Vec::new(),
1170 author: b"Tester <test@example.com> 1700000000 +0000".to_vec(),
1171 committer: b"Tester <test@example.com> 1700000000 +0000".to_vec(),
1172 encoding: None,
1173 message: b"initial\n".to_vec(),
1174 };
1175 let commit_oid = db
1176 .write_object(EncodedObject::new(ObjectType::Commit, commit.write()))
1177 .expect("write commit");
1178
1179 let refs = repo.references();
1180 refs.create_branch(
1181 "main",
1182 commit_oid,
1183 b"Tester <test@example.com> 1700000000 +0000".to_vec(),
1184 b"commit (initial): initial".to_vec(),
1185 )
1186 .expect("create main branch");
1187
1188 commit_oid
1189 }
1190
1191 fn seed_empty_tree_commit(repo: &Repository) -> ObjectId {
1192 let db = repo.objects_mut();
1193 let commit = Commit {
1194 tree: ObjectId::empty_tree(repo.object_format()),
1195 parents: Vec::new(),
1196 author: b"Tester <test@example.com> 1700000000 +0000".to_vec(),
1197 committer: b"Tester <test@example.com> 1700000000 +0000".to_vec(),
1198 encoding: None,
1199 message: b"empty tree\n".to_vec(),
1200 };
1201 db.write_object(EncodedObject::new(ObjectType::Commit, commit.write()))
1202 .expect("write empty tree commit")
1203 }
1204
1205 fn seed_child_commit(repo: &Repository, parent: ObjectId, message: &[u8]) -> ObjectId {
1206 let db = repo.objects_mut();
1207 let blob_oid = db
1208 .write_object(EncodedObject::new(
1209 ObjectType::Blob,
1210 [message, b"\n"].concat(),
1211 ))
1212 .expect("write child blob");
1213 let tree = Tree {
1214 entries: vec![sley_object::TreeEntry {
1215 mode: 0o100644,
1216 name: BString::from(b"child.txt"),
1217 oid: blob_oid,
1218 }],
1219 };
1220 let tree_oid = db
1221 .write_object(EncodedObject::new(ObjectType::Tree, tree.write()))
1222 .expect("write child tree");
1223 let commit = Commit {
1224 tree: tree_oid,
1225 parents: vec![parent],
1226 author: b"Tester <test@example.com> 1700000001 +0000".to_vec(),
1227 committer: b"Tester <test@example.com> 1700000001 +0000".to_vec(),
1228 encoding: None,
1229 message: message.to_vec(),
1230 };
1231 db.write_object(EncodedObject::new(ObjectType::Commit, commit.write()))
1232 .expect("write child commit")
1233 }
1234
1235 #[test]
1236 fn init_creates_repo_and_open_reads_it_back() {
1237 let temp = TempDir::new();
1238 let repo = Repository::init(temp.path()).expect("init");
1239 assert_eq!(repo.git_dir(), temp.path().join(".git"));
1240 assert_eq!(repo.object_format(), ObjectFormat::Sha1);
1241 assert!(repo.git_dir().join("HEAD").is_file());
1242
1243 let reopened = Repository::open(temp.path().join(".git")).expect("open");
1245 assert_eq!(reopened.git_dir(), repo.git_dir());
1246 assert_eq!(reopened.object_format(), ObjectFormat::Sha1);
1247 }
1248
1249 #[test]
1250 fn init_bare_uses_path_as_git_dir() {
1251 let temp = TempDir::new();
1252 let repo = Repository::init_bare(temp.path()).expect("init bare");
1253 assert_eq!(repo.git_dir(), temp.path());
1255 assert!(repo.git_dir().join("HEAD").is_file());
1256 assert!(repo.git_dir().join("objects").is_dir());
1257
1258 let reopened = Repository::open(temp.path()).expect("open bare");
1259 assert_eq!(reopened.git_dir(), temp.path());
1260 }
1261
1262 #[test]
1263 fn open_exact_bare_never_discovers_parent_repo() {
1264 let temp = TempDir::new();
1265 Repository::init(temp.path()).expect("init parent");
1266 let scratch = temp.path().join("nested").join("scratch.git");
1267 fs::create_dir_all(&scratch).expect("create scratch path");
1268
1269 Repository::open_exact_bare(&scratch).expect_err("exact open must not discover parent");
1270 let discovered = Repository::discover(&scratch).expect("discover parent repo");
1271 assert_eq!(discovered.git_dir(), temp.path().join(".git"));
1272
1273 let bare = TempDir::new();
1274 let bare_repo = Repository::init_bare(bare.path()).expect("init bare");
1275 let exact = Repository::open_exact_bare(bare.path()).expect("open exact bare");
1276 assert_eq!(exact.git_dir(), bare_repo.git_dir());
1277 }
1278
1279 #[test]
1280 fn head_is_unborn_after_init() {
1281 let temp = TempDir::new();
1282 let repo = Repository::init(temp.path()).expect("init");
1283 let head = repo.head().expect("head");
1284 assert_eq!(
1285 head.symbolic_target.as_ref().map(FullName::as_str),
1286 Some("refs/heads/main")
1287 );
1288 assert_eq!(head.oid, None);
1289 assert!(head.is_unborn());
1290 assert!(!head.is_detached());
1291 assert_eq!(head.branch_name(), Some("main"));
1292
1293 let state = repo.head_state().expect("head state");
1294 assert!(state.is_unborn());
1295 assert_eq!(
1296 state.symbolic_target().map(FullName::as_str),
1297 Some("refs/heads/main")
1298 );
1299 assert_eq!(
1300 state.immediate_target(),
1301 Some(RefTarget::Symbolic("refs/heads/main".into()))
1302 );
1303 }
1304
1305 #[test]
1306 fn set_head_symref_attaches_head_and_writes_reflog() {
1307 let temp = TempDir::new();
1308 let repo = Repository::init(temp.path()).expect("init");
1309 let main = seed_commit(&repo);
1310 let topic = seed_child_commit(&repo, main, b"topic\n");
1311 repo.apply_ref_changes(&[
1312 RefChange::new("refs/heads/topic", RefTarget::Direct(topic)).expect("valid ref")
1313 ])
1314 .expect("create topic");
1315 let committer = b"Tester <test@example.com> 1700000002 +0000".to_vec();
1316
1317 repo.set_head_symref(
1318 "refs/heads/topic",
1319 HeadUpdateOptions::new()
1320 .expect_current(RefTarget::Symbolic("refs/heads/main".into()))
1321 .reflog(b"checkout: moving from main to topic".to_vec())
1322 .reflog_committer(committer.clone()),
1323 )
1324 .expect("set HEAD symref");
1325
1326 match repo.head_state().expect("head state") {
1327 HeadState::Attached { target, oid, .. } => {
1328 assert_eq!(target.as_str(), "refs/heads/topic");
1329 assert_eq!(oid, topic);
1330 }
1331 other => panic!("expected attached HEAD, got {other:?}"),
1332 }
1333 assert_eq!(
1334 repo.references().read_ref("HEAD").expect("read HEAD"),
1335 Some(RefTarget::Symbolic("refs/heads/topic".into()))
1336 );
1337 let head_log = repo.references().read_reflog("HEAD").expect("HEAD log");
1338 let last = head_log.last().expect("HEAD reflog entry");
1339 assert_eq!(last.old_oid, main);
1340 assert_eq!(last.new_oid, topic);
1341 assert_eq!(last.committer, committer);
1342 assert_eq!(last.message, b"checkout: moving from main to topic");
1343 }
1344
1345 #[test]
1346 fn reference_helpers_keep_direct_tag_target_separate_from_peeling() {
1347 let temp = TempDir::new();
1348 let repo = Repository::init(temp.path()).expect("init");
1349 let commit_oid = seed_commit(&repo);
1350 let tag = Tag {
1351 object: commit_oid,
1352 object_type: ObjectType::Commit,
1353 name: b"v1.0".to_vec(),
1354 tagger: Some(b"Tester <test@example.com> 1700000001 +0000".to_vec()),
1355 message: b"release\n".to_vec(),
1356 raw_body: None,
1357 };
1358 let tag_oid = repo
1359 .write_object(EncodedObject::new(ObjectType::Tag, tag.write()))
1360 .expect("write tag");
1361 repo.apply_ref_changes(&[
1362 RefChange::new("refs/tags/v1.0", RefTarget::Direct(tag_oid)).expect("valid tag ref")
1363 ])
1364 .expect("write tag ref");
1365
1366 assert!(
1367 repo.reference_exists("refs/tags/v1.0")
1368 .expect("exists check")
1369 );
1370 let tag_ref = repo
1371 .require_reference("refs/tags/v1.0")
1372 .expect("require tag ref");
1373 assert_eq!(tag_ref.direct_target().expect("direct target"), tag_oid);
1374 assert_eq!(
1375 repo.peel_to_object_oid(tag_oid).expect("peel object"),
1376 commit_oid
1377 );
1378 assert_eq!(
1379 repo.peel_to_commit_oid(tag_oid).expect("peel commit"),
1380 commit_oid
1381 );
1382 }
1383
1384 #[test]
1385 fn blob_boundary_and_status_plan_are_embedder_facing_facades() {
1386 let temp = TempDir::new();
1387 let repo = Repository::init(temp.path()).expect("init");
1388 let blob_oid = repo.write_blob(b"payload").expect("write blob");
1389
1390 let bytes = repo
1391 .blobs()
1392 .read_or_fetch_blocking(blob_oid, BlobFetchOptions::from_remote("origin"))
1393 .expect("read local blob");
1394 assert_eq!(bytes, b"payload");
1395
1396 let missing = ObjectId::null(repo.object_format());
1397 let err = repo
1398 .blobs()
1399 .read_or_fetch_blocking(missing, BlobFetchOptions::from_remote("origin"))
1400 .expect_err("missing blob");
1401 match err.not_found_kind() {
1402 Some(NotFoundKind::Object { oid, kind, context }) => {
1403 assert_eq!(*oid, missing);
1404 assert_eq!(*kind, MissingObjectKind::Blob);
1405 assert_eq!(*context, Some(MissingObjectContext::RemoteBoundary));
1406 }
1407 other => panic!("expected typed missing blob, got {other:?}"),
1408 }
1409
1410 let status = repo
1411 .status_plan()
1412 .include_untracked(false)
1413 .reuse_index_cache("health")
1414 .build()
1415 .expect("status plan");
1416 assert_eq!(
1417 status.cache_key().map(StatusCacheKey::as_str),
1418 Some("health")
1419 );
1420 assert_eq!(status.count().expect("count status"), 0);
1421 assert!(status.collect().expect("collect status").is_empty());
1422
1423 fs::write(temp.path().join("untracked.txt"), b"new\n").expect("write untracked");
1424 let status = repo
1425 .status_plan()
1426 .include_untracked(true)
1427 .build()
1428 .expect("status plan");
1429 let mut streamed = Vec::new();
1430 status
1431 .stream(|row| {
1432 streamed.push(row.to_owned());
1433 Ok(StreamControl::Stop)
1434 })
1435 .expect("stream one row");
1436 assert_eq!(streamed.len(), 1);
1437 assert_eq!(streamed[0].worktree, StatusCode::Untracked);
1438 assert_eq!(streamed[0].path, b"untracked.txt");
1439 let collected = status.collect_rows().expect("collect typed rows");
1440 assert_eq!(collected.len(), 1);
1441 assert_eq!(collected[0].path, b"untracked.txt");
1442 }
1443
1444 #[test]
1445 fn head_resolves_after_commit() {
1446 let temp = TempDir::new();
1447 let repo = Repository::init(temp.path()).expect("init");
1448 let commit_oid = seed_commit(&repo);
1449
1450 let head = repo.head().expect("head");
1451 assert_eq!(
1452 head.symbolic_target.as_ref().map(FullName::as_str),
1453 Some("refs/heads/main")
1454 );
1455 assert_eq!(head.oid.as_ref(), Some(&commit_oid));
1456 assert!(!head.is_unborn());
1457 assert_eq!(head.branch_name(), Some("main"));
1458 }
1459
1460 #[test]
1461 fn read_object_commit_and_tree_round_trip() {
1462 let temp = TempDir::new();
1463 let repo = Repository::init(temp.path()).expect("init");
1464 let commit_oid = seed_commit(&repo);
1465
1466 let raw = repo.read_object(&commit_oid).expect("read object");
1468 assert_eq!(raw.object_type, ObjectType::Commit);
1469
1470 let commit = repo.read_commit(&commit_oid).expect("read commit");
1472 assert_eq!(commit.message, b"initial\n");
1473 assert!(commit.parents.is_empty());
1474
1475 let tree = repo.read_tree(&commit.tree).expect("read tree");
1477 assert_eq!(tree.entries.len(), 1);
1478 assert_eq!(tree.entries[0].name, b"hello.txt");
1479
1480 let blob = repo.read_object(&tree.entries[0].oid).expect("read blob");
1482 assert_eq!(blob.object_type, ObjectType::Blob);
1483 assert_eq!(blob.body, b"hello\n");
1484 }
1485
1486 #[test]
1487 fn read_tree_accepts_implied_empty_tree_without_stored_object() {
1488 let temp = TempDir::new();
1489 let repo = Repository::init(temp.path()).expect("init");
1490 let empty = ObjectId::empty_tree(repo.object_format());
1491
1492 let object = repo.read_object(&empty).expect("read implied empty tree");
1493 assert_eq!(object.object_type, ObjectType::Tree);
1494 assert!(object.body.is_empty());
1495
1496 let tree = repo.read_tree(&empty).expect("parse implied empty tree");
1497 assert!(tree.entries.is_empty());
1498 }
1499
1500 #[test]
1501 fn missing_object_errors_expose_oid_and_expected_kind() {
1502 let temp = TempDir::new();
1503 let repo = Repository::init(temp.path()).expect("init");
1504 let missing = ObjectId::from_hex(
1505 repo.object_format(),
1506 "1111111111111111111111111111111111111111",
1507 )
1508 .expect("valid oid");
1509
1510 let raw_err = repo.read_object(&missing).expect_err("raw missing object");
1511 let raw_kind = raw_err.not_found_kind().expect("typed not found");
1512 assert_eq!(raw_kind.object_id(), Some(missing));
1513 assert_eq!(
1514 raw_kind.missing_object_kind(),
1515 Some(MissingObjectKind::Object)
1516 );
1517 assert_eq!(
1518 raw_kind.missing_object_context(),
1519 Some(MissingObjectContext::Read)
1520 );
1521
1522 let commit_err = repo
1523 .read_commit(&missing)
1524 .expect_err("typed missing commit");
1525 let commit_kind = commit_err.not_found_kind().expect("typed not found");
1526 assert_eq!(commit_kind.object_id(), Some(missing));
1527 assert_eq!(
1528 commit_kind.missing_object_kind(),
1529 Some(MissingObjectKind::Commit)
1530 );
1531 assert_eq!(
1532 commit_kind.missing_object_context(),
1533 Some(MissingObjectContext::Read)
1534 );
1535 }
1536
1537 #[test]
1538 fn read_commit_accepts_encoded_non_utf8_commit() {
1539 let temp = TempDir::new();
1540 let repo = Repository::init(temp.path()).expect("init");
1541 let tree = ObjectId::empty_tree(repo.object_format());
1542 let mut body = Vec::new();
1543 body.extend_from_slice(format!("tree {tree}\n").as_bytes());
1544 body.extend_from_slice(b"author J\xF6rg <j@example.invalid> 0 +0000\n");
1545 body.extend_from_slice(b"committer M\xFCller <m@example.invalid> 1 +0000\n");
1546 body.extend_from_slice(b"encoding ISO-8859-1\n\ncaf\xE9\n");
1547 let oid = repo
1548 .write_raw_object(ObjectType::Commit, body)
1549 .expect("write raw commit");
1550
1551 let commit = repo.read_commit(&oid).expect("read non-utf8 commit");
1552 assert_eq!(commit.author, b"J\xF6rg <j@example.invalid> 0 +0000");
1553 assert_eq!(commit.committer, b"M\xFCller <m@example.invalid> 1 +0000");
1554 assert_eq!(commit.encoding.as_deref(), Some(&b"ISO-8859-1"[..]));
1555 assert_eq!(commit.message, b"caf\xE9\n");
1556 }
1557
1558 #[test]
1559 fn read_commit_rejects_non_commit() {
1560 let temp = TempDir::new();
1561 let repo = Repository::init(temp.path()).expect("init");
1562 let commit_oid = seed_commit(&repo);
1563 let commit = repo.read_commit(&commit_oid).expect("read commit");
1564
1565 let err = repo
1567 .read_commit(&commit.tree)
1568 .expect_err("reading a tree as a commit must fail");
1569 assert!(matches!(err, GitError::InvalidObject(_)));
1570 }
1571
1572 #[test]
1573 fn rev_parse_resolves_branch_and_head() {
1574 let temp = TempDir::new();
1575 let repo = Repository::init(temp.path()).expect("init");
1576 let commit_oid = seed_commit(&repo);
1577
1578 assert_eq!(repo.rev_parse("HEAD").expect("HEAD"), commit_oid);
1579 assert_eq!(repo.rev_parse("main").expect("main"), commit_oid);
1580 assert_eq!(
1581 repo.rev_parse("refs/heads/main").expect("full ref"),
1582 commit_oid
1583 );
1584 assert_eq!(
1586 repo.rev_parse(&commit_oid.to_hex()).expect("hex"),
1587 commit_oid
1588 );
1589 }
1590
1591 #[test]
1592 fn find_reference_returns_branch_and_head() {
1593 let temp = TempDir::new();
1594 let repo = Repository::init(temp.path()).expect("init");
1595 let commit_oid = seed_commit(&repo);
1596
1597 let branch = repo
1598 .find_reference("refs/heads/main")
1599 .expect("find branch")
1600 .expect("branch exists");
1601 assert_eq!(branch.name, "refs/heads/main");
1602 assert_eq!(branch.target, RefTarget::Direct(commit_oid));
1603 assert_eq!(branch.peeled_oid(&repo).expect("peel"), Some(commit_oid));
1604
1605 let head = repo
1606 .find_reference("HEAD")
1607 .expect("find head")
1608 .expect("head exists");
1609 assert_eq!(head.target, RefTarget::Symbolic("refs/heads/main".into()));
1610 assert_eq!(head.peeled_oid(&repo).expect("peel head"), Some(commit_oid));
1612
1613 assert!(
1615 repo.find_reference("refs/heads/missing")
1616 .expect("missing lookup")
1617 .is_none()
1618 );
1619 }
1620
1621 #[test]
1622 fn discover_finds_repo_from_nested_subdirectory() {
1623 let temp = TempDir::new();
1624 let repo = Repository::init(temp.path()).expect("init");
1625 let nested = temp.path().join("a").join("b").join("c");
1626 fs::create_dir_all(&nested).expect("nested dirs");
1627
1628 let discovered = Repository::discover(&nested).expect("discover");
1629 assert_eq!(
1631 fs::canonicalize(discovered.git_dir()).expect("canon discovered"),
1632 fs::canonicalize(repo.git_dir()).expect("canon repo")
1633 );
1634 }
1635
1636 #[test]
1637 fn discover_errors_outside_any_repo() {
1638 let temp = TempDir::new();
1639 let err =
1642 Repository::discover(temp.path()).expect_err("discovering outside any repo must fail");
1643 assert!(matches!(err, GitError::NotFound(_)));
1644 }
1645
1646 #[test]
1647 fn open_rejects_non_git_directory() {
1648 let temp = TempDir::new();
1649 let err = Repository::open(temp.path()).expect_err("opening a non-git directory must fail");
1650 assert!(matches!(err, GitError::NotFound(_)));
1651 }
1652
1653 #[test]
1654 fn config_round_trips_and_reports_format() {
1655 let temp = TempDir::new();
1656 let repo = Repository::init(temp.path()).expect("init");
1657 let config = repo.config().expect("config");
1658 assert_eq!(config.get("core", None, "bare"), Some("false"));
1660 assert_eq!(
1661 config.repository_object_format().expect("format"),
1662 ObjectFormat::Sha1
1663 );
1664 }
1665
1666 #[test]
1667 fn sha256_repository_round_trips() {
1668 let temp = TempDir::new();
1669 let repo = Repository::init_with_format(temp.path(), ObjectFormat::Sha256, false)
1670 .expect("init sha256");
1671 assert_eq!(repo.object_format(), ObjectFormat::Sha256);
1672
1673 let reopened = Repository::open(temp.path().join(".git")).expect("open");
1675 assert_eq!(reopened.object_format(), ObjectFormat::Sha256);
1676
1677 let commit_oid = seed_commit(&repo);
1678 assert_eq!(commit_oid.format(), ObjectFormat::Sha256);
1679 assert_eq!(repo.rev_parse("HEAD").expect("HEAD"), commit_oid);
1680 }
1681
1682 #[test]
1683 fn config_snapshot_reads_repository_layer_via_helpers() {
1684 let temp = TempDir::new();
1685 let repo = Repository::init(temp.path()).expect("init");
1686 let config_path = repo.common_dir().join("config");
1692 let mut contents = fs::read(&config_path).expect("read config");
1693 contents.extend_from_slice(
1694 b"[user]\n\tname = Snapshot Person\n\temail = snap@example.invalid\n\
1695 [remote \"origin\"]\n\turl = https://example.invalid/x.git\n",
1696 );
1697 fs::write(&config_path, contents).expect("write config");
1698
1699 let snapshot = repo.config_snapshot().expect("snapshot");
1701 assert_eq!(snapshot.get("user", None, "name"), Some("Snapshot Person"));
1702
1703 assert_eq!(
1705 repo.config_string("user", "name").expect("name"),
1706 Some("Snapshot Person".to_string())
1707 );
1708 assert_eq!(
1709 repo.config_string("user", "email").expect("email"),
1710 Some("snap@example.invalid".to_string())
1711 );
1712 assert_eq!(
1713 repo.config_string("user", "missing").expect("missing"),
1714 None
1715 );
1716
1717 assert_eq!(
1719 repo.config_string_subsection("remote", Some("origin"), "url")
1720 .expect("url"),
1721 Some("https://example.invalid/x.git".to_string())
1722 );
1723 }
1724
1725 #[test]
1726 fn workdir_is_parent_for_non_bare_repo() {
1727 let temp = TempDir::new();
1728 let repo = Repository::init(temp.path()).expect("init");
1729 assert_eq!(repo.workdir(), Some(temp.path().to_path_buf()));
1732 }
1733
1734 #[test]
1735 fn workdir_is_none_for_bare_repo() {
1736 let temp = TempDir::new();
1737 let repo = Repository::init_bare(temp.path()).expect("init bare");
1738 assert_eq!(repo.workdir(), None);
1739 }
1740
1741 #[test]
1742 fn workdir_honours_core_worktree_override() {
1743 let temp = TempDir::new();
1744 let repo = Repository::init(temp.path()).expect("init");
1745 let elsewhere = temp.path().join("elsewhere");
1747 fs::create_dir_all(&elsewhere).expect("create worktree dir");
1748 let config_path = repo.git_dir().join("config");
1749 let mut contents = fs::read(&config_path).expect("read config");
1750 contents.extend_from_slice(
1751 format!("[core]\n\tworktree = {}\n", elsewhere.display()).as_bytes(),
1752 );
1753 fs::write(&config_path, contents).expect("write config");
1754
1755 assert_eq!(
1757 repo.workdir(),
1758 Some(fs::canonicalize(&elsewhere).expect("canon worktree"))
1759 );
1760 }
1761
1762 #[test]
1763 fn is_shallow_tracks_shallow_file() {
1764 let temp = TempDir::new();
1765 let repo = Repository::init(temp.path()).expect("init");
1766 assert!(!repo.is_shallow());
1767 fs::write(repo.git_dir().join("shallow"), b"").expect("write shallow");
1768 assert!(repo.is_shallow());
1769 }
1770
1771 #[test]
1772 fn remote_names_lists_configured_remotes_sorted() {
1773 let temp = TempDir::new();
1774 let repo = Repository::init(temp.path()).expect("init");
1775 assert_eq!(repo.remote_names().expect("names"), Vec::<String>::new());
1776
1777 let config_path = repo.common_dir().join("config");
1778 let mut contents = fs::read(&config_path).expect("read config");
1779 contents.extend_from_slice(
1781 b"[remote \"upstream\"]\n\turl = https://example.invalid/up.git\n\
1782 [remote \"origin\"]\n\turl = https://example.invalid/o.git\n\
1783 [remote \"origin\"]\n\tpushurl = https://example.invalid/o-push.git\n",
1784 );
1785 fs::write(&config_path, contents).expect("write config");
1786
1787 assert_eq!(
1788 repo.remote_names().expect("names"),
1789 vec!["origin".to_string(), "upstream".to_string()]
1790 );
1791 }
1792
1793 #[test]
1794 fn plumbing_reexports_are_reachable() {
1795 let _format: plumbing::sley_core::ObjectFormat = ObjectFormat::Sha1;
1797 let _: fn(&[u8]) -> Result<plumbing::sley_config::GitConfig> =
1798 plumbing::sley_config::GitConfig::parse;
1799 let _: plumbing::sley_diff_merge::DiffNameStatusOptions =
1800 plumbing::sley_diff_merge::DiffNameStatusOptions::default();
1801 let _: fn(&mut plumbing::sley_odb::FileObjectDatabase, TagCreate) -> Result<ObjectId> =
1802 plumbing::sley_sequencer::create_annotated_tag;
1803 }
1804
1805 #[test]
1806 fn capabilities_reflect_repo_state() {
1807 let temp = TempDir::new();
1808 let repo = Repository::init(temp.path()).expect("init");
1809 let caps = repo.capabilities();
1810 assert!(caps.annotated_tags);
1811 assert!(caps.config_includes);
1812 assert!(caps.hasconfig_include_if);
1813 assert!(caps.notes);
1814 assert!(caps.index);
1815 assert!(!caps.shallow);
1816 assert!(!caps.sha256);
1817
1818 fs::write(repo.git_dir().join("shallow"), b"").expect("shallow");
1819 assert!(repo.capabilities().shallow);
1820 }
1821
1822 #[test]
1823 fn resolve_path_finds_blob_in_commit() {
1824 let temp = TempDir::new();
1825 let repo = Repository::init(temp.path()).expect("init");
1826 seed_commit(&repo);
1827
1828 let entry = repo
1829 .resolve_path("HEAD", "hello.txt")
1830 .expect("resolve path");
1831 assert_eq!(entry.name, b"hello.txt");
1832 assert_eq!(entry.object_type, ObjectType::Blob);
1833 assert!(entry.mode.is_some());
1834 }
1835
1836 #[test]
1837 fn remote_edit_round_trip() {
1838 let temp = TempDir::new();
1839 let repo = Repository::init(temp.path()).expect("init");
1840
1841 repo.add_remote("origin", "https://example.invalid/o.git")
1842 .expect("add");
1843 assert_eq!(
1844 repo.remote_names().expect("names"),
1845 vec!["origin".to_string()]
1846 );
1847 assert_eq!(
1848 repo.config_string_subsection("remote", Some("origin"), "url")
1849 .expect("url"),
1850 Some("https://example.invalid/o.git".to_string())
1851 );
1852
1853 repo.set_remote_url("origin", "https://example.invalid/n.git")
1854 .expect("set url");
1855 assert_eq!(
1856 repo.config_string_subsection("remote", Some("origin"), "url")
1857 .expect("url"),
1858 Some("https://example.invalid/n.git".to_string())
1859 );
1860
1861 repo.remove_remote("origin").expect("remove");
1862 assert!(repo.remote_names().expect("names").is_empty());
1863 }
1864
1865 #[test]
1866 fn init_mirror_writes_origin_fetch_and_mirror() {
1867 let temp = TempDir::new();
1868 let repo = Repository::init_mirror(temp.path()).expect("init mirror");
1869 assert_eq!(repo.workdir(), None);
1870 let config = repo.load_repo_config().expect("config");
1871 assert_eq!(
1872 config.get("remote", Some("origin"), "fetch"),
1873 Some("+refs/*:refs/*")
1874 );
1875 assert_eq!(config.get("remote", Some("origin"), "mirror"), Some("true"));
1876 }
1877
1878 #[test]
1879 fn copy_reachable_from_transfers_missing_objects() {
1880 let source_dir = TempDir::new();
1881 let dest_dir = TempDir::new();
1882 let source = Repository::init(source_dir.path()).expect("source");
1883 let dest = Repository::init(dest_dir.path()).expect("dest");
1884 let commit_oid = seed_commit(&source);
1885
1886 dest.copy_reachable_from(&source, std::slice::from_ref(&commit_oid))
1887 .expect("copy");
1888
1889 let copied = dest.read_commit(&commit_oid).expect("read copied commit");
1890 let original = source.read_commit(&commit_oid).expect("read source commit");
1891 assert_eq!(copied.tree, original.tree);
1892 assert_eq!(copied.message, original.message);
1893 }
1894
1895 #[test]
1896 fn copy_reachable_from_accepts_implied_empty_tree() {
1897 let source_dir = TempDir::new();
1898 let dest_dir = TempDir::new();
1899 let source = Repository::init(source_dir.path()).expect("source");
1900 let dest = Repository::init(dest_dir.path()).expect("dest");
1901 let commit_oid = seed_empty_tree_commit(&source);
1902
1903 dest.copy_reachable_from(&source, std::slice::from_ref(&commit_oid))
1904 .expect("copy empty-tree commit");
1905
1906 let copied = dest.read_commit(&commit_oid).expect("read copied commit");
1907 assert_eq!(copied.tree, ObjectId::empty_tree(dest.object_format()));
1908 assert!(
1909 dest.read_tree(&copied.tree)
1910 .expect("read tree")
1911 .entries
1912 .is_empty()
1913 );
1914 }
1915
1916 #[test]
1917 fn rev_graph_streams_and_counts_ancestry() {
1918 let temp = TempDir::new();
1919 let repo = Repository::init(temp.path()).expect("init");
1920 let base = seed_commit(&repo);
1921 let tip = seed_child_commit(&repo, base, b"second\n");
1922 repo.apply_ref_changes(&[
1923 RefChange::new("refs/heads/main", RefTarget::Direct(tip)).expect("valid ref")
1924 ])
1925 .expect("advance main");
1926
1927 let graph = repo.rev_graph();
1928 assert!(graph.is_ancestor(base, tip).expect("ancestor check"));
1929 assert!(!graph.is_ancestor(tip, base).expect("reverse check"));
1930 assert_eq!(graph.ahead_behind(tip, base).expect("ahead behind"), (1, 0));
1931
1932 let mut streamed = Vec::new();
1933 graph
1934 .stream_reachable_commits([tip], ReachableCommitOptions::new(), |commit| {
1935 streamed.push(commit.oid);
1936 Ok(StreamControl::Stop)
1937 })
1938 .expect("stream one commit");
1939 assert_eq!(streamed, vec![tip]);
1940
1941 let collected = graph
1942 .collect_reachable_commits([tip], ReachableCommitOptions::new())
1943 .expect("collect commits");
1944 assert_eq!(collected.len(), 2);
1945 assert_eq!(collected[0].oid, tip);
1946 assert_eq!(collected[1].oid, base);
1947 }
1948
1949 #[test]
1950 fn reachable_pack_plan_freezes_selection_and_prepares_once() {
1951 let temp = TempDir::new();
1952 let repo = Repository::init(temp.path()).expect("init");
1953 let commit_oid = seed_commit(&repo);
1954
1955 let plan = repo
1956 .reachable_pack_plan()
1957 .root(commit_oid)
1958 .build()
1959 .expect("build pack plan")
1960 .expect("reachable objects");
1961 assert!(plan.object_count() >= 3);
1962 assert_eq!(plan.object_format(), repo.object_format());
1963 assert!(plan.object_ids().contains(&commit_oid));
1964
1965 let prepared = plan.prepare_to_memory().expect("prepare memory");
1966 assert!(prepared.pack.starts_with(b"PACK"));
1967 assert_eq!(prepared.summary.object_count, plan.object_count());
1968 assert_eq!(prepared.summary.pack_size, prepared.pack.len() as u64);
1969 assert!(!prepared.index.is_empty());
1970
1971 let mut streamed = Vec::new();
1972 let streamed_summary = plan.stream_to(&mut streamed).expect("stream pack");
1973 assert_eq!(streamed, prepared.pack);
1974 assert_eq!(streamed_summary, prepared.summary);
1975
1976 let pack_path = temp.path().join("planned.pack");
1977 let prepared_file = plan.prepare_to_file(&pack_path).expect("prepare file");
1978 assert_eq!(prepared_file.summary, prepared.summary);
1979 assert_eq!(fs::read(pack_path).expect("read pack file"), prepared.pack);
1980
1981 let none = repo
1982 .reachable_pack_plan()
1983 .root(commit_oid)
1984 .exclude(commit_oid)
1985 .build()
1986 .expect("excluded root plan");
1987 assert!(none.is_none());
1988 }
1989
1990 #[test]
1991 fn write_annotated_tag_round_trips() {
1992 let temp = TempDir::new();
1993 let repo = Repository::init(temp.path()).expect("init");
1994 let commit_oid = seed_commit(&repo);
1995
1996 let tag_oid = repo
1997 .write_annotated_tag(TagCreate {
1998 object: commit_oid,
1999 object_type: ObjectType::Commit,
2000 name: b"v1".to_vec(),
2001 tagger: b"Tagger <t@e.com> 1 +0000".to_vec(),
2002 message: b"release\n".to_vec(),
2003 })
2004 .expect("tag");
2005 let tag = repo.read_tag(&tag_oid).expect("read tag");
2006 assert_eq!(tag.name, b"v1");
2007 assert_eq!(tag.object, commit_oid);
2008 }
2009
2010 #[test]
2011 fn diff_name_status_reports_added_file() {
2012 let temp = TempDir::new();
2013 let repo = Repository::init(temp.path()).expect("init");
2014 let commit_oid = seed_commit(&repo);
2015 let base_tree = repo.read_commit(&commit_oid).expect("commit").tree;
2016
2017 let mut editor = repo.edit_tree(&base_tree).expect("edit");
2018 let blob_oid = repo.write_blob(b"new\n").expect("blob");
2019 editor.upsert("added.txt", sley_object::EntryKind::Blob, blob_oid);
2020 let new_tree = repo.write_tree(editor).expect("tree");
2021
2022 let changes = repo.diff_name_status(&base_tree, &new_tree).expect("diff");
2023 assert_eq!(changes.len(), 1);
2024 assert_eq!(changes[0].path, b"added.txt");
2025 }
2026}