Skip to main content

sley/
lib.rs

1//! `git` — the ergonomic facade over the sley engine.
2//!
3//! Downstream code that wants a "just open a repository and read things" entry
4//! point should reach for [`Repository`] rather than wiring the plumbing crates
5//! together by hand. A [`Repository`] is a lightweight handle around a resolved
6//! git directory: it remembers the `git_dir`, the common directory (for linked
7//! worktrees), and the repository's object format, and hands back the
8//! underlying plumbing objects ([`sley_odb::FileObjectDatabase`],
9//! [`sley_refs::FileRefStore`], [`sley_config::GitConfig`]) on demand.
10//!
11//! **Heddle / embedder surface** (feature `remote`, on by default):
12//!
13//! * [`Repository::config_snapshot`] — full git config with `include` /
14//!   `includeIf` / `hasconfig:` resolution.
15//! * [`Repository::init_mirror`] — bare mirror defaults (`+refs/*:refs/*`,
16//!   `mirror = true` on `origin`).
17//! * [`Repository::copy_reachable_from`] — pack-based object transfer.
18//! * [`Repository::remote`] / [`remote::RemoteContext`] — URL rewriting and
19//!   [`sley_remote`] fetch/push/clone/ls-remote orchestration (HTTP v2 fetch,
20//!   SSH, bundle fetch, thin-pack push).
21//! * [`notes`] — git notes read/write for round-trip fidelity.
22//! * [`Repository::capabilities`] / [`Repository::transport_capabilities`] —
23//!   capability probes before calling in.
24//!
25//! For power users the engine crates are re-exported under [`plumbing`] (and the
26//! most common types are re-exported at the crate root), so a single
27//! `git = { path = ... }` dependency is enough to reach the whole stack.
28//!
29//! ```no_run
30//! use sley::Repository;
31//!
32//! # fn main() -> sley::Result<()> {
33//! let repo = Repository::discover(".")?;
34//! let head = repo.head()?;
35//! if let Some(oid) = head.oid {
36//!     let commit = repo.read_commit(&oid)?;
37//!     let _ = commit.tree;
38//! }
39//! # Ok(())
40//! # }
41//! ```
42
43mod 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
67/// Git notes read/write ([`sley_notes`]).
68pub mod notes {
69    pub use sley_notes::*;
70}
71
72/// Re-exports of the underlying plumbing crates for callers that need direct
73/// access to the engine. Everything reachable through [`Repository`] is built
74/// from these, and they remain available for the operations the facade does not
75/// (yet) wrap.
76pub 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
97// The most frequently used plumbing types are also re-exported at the crate root
98// so the common path (`use sley::{Repository, ObjectId, ...}`) stays short.
99pub 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/// A resolved reference: its full name plus the target it points at.
148///
149/// `target` is the immediate target as stored on disk (a direct [`ObjectId`] or
150/// a symbolic pointer to another ref name), while [`Reference::peeled_oid`]
151/// follows symbolic chains to the final object id.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct Reference {
154    /// The full reference name, e.g. `refs/heads/main` or `HEAD`.
155    pub name: FullName,
156    /// The reference's immediate target.
157    pub target: RefTarget,
158}
159
160impl Reference {
161    /// Return this reference's immediate direct object id.
162    ///
163    /// This intentionally does not follow symbolic refs and does not peel
164    /// annotated tags. Use [`Repository::require_reference`] first when absence
165    /// is a hard error, then choose explicit symbolic resolution or object
166    /// peeling at the call site.
167    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    /// Borrow this reference's immediate on-disk target.
178    pub fn immediate_target(&self) -> &RefTarget {
179        &self.target
180    }
181
182    /// The object id this reference resolves to, if it is (or chains to) a
183    /// direct reference.
184    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/// The resolved state of `HEAD`.
193///
194/// A repository freshly created by `git init` has `HEAD` pointing at a branch
195/// that does not exist yet ("unborn"); in that case [`Head::oid`] is `None`
196/// while [`Head::symbolic_target`] still names the branch ref.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct Head {
199    /// The branch ref `HEAD` symbolically points at (e.g. `refs/heads/main`),
200    /// or `None` when `HEAD` is detached (points directly at a commit).
201    pub symbolic_target: Option<FullName>,
202    /// The commit `HEAD` resolves to, or `None` for an unborn branch.
203    pub oid: Option<ObjectId>,
204}
205
206impl Head {
207    /// Whether `HEAD` points at a branch that does not exist yet.
208    pub fn is_unborn(&self) -> bool {
209        self.symbolic_target.is_some() && self.oid.is_none()
210    }
211
212    /// Whether `HEAD` points directly at a commit rather than a branch.
213    pub fn is_detached(&self) -> bool {
214        self.symbolic_target.is_none() && self.oid.is_some()
215    }
216
217    /// The short branch name (`refs/heads/<name>` stripped to `<name>`) `HEAD`
218    /// points at, if any.
219    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/// Typed, immediate state of `HEAD`.
228///
229/// Unlike [`Head`], this preserves the raw on-disk target separately from the
230/// resolved commit id. That lets embedders distinguish an unborn attached HEAD
231/// from a detached HEAD and from malformed/missing state without parsing
232/// `.git/HEAD` themselves.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum HeadState {
235    /// `HEAD` is missing.
236    Missing,
237    /// `HEAD` points at `target`, but that ref does not yet exist.
238    Unborn {
239        target: FullName,
240        raw_target: String,
241    },
242    /// `HEAD` points symbolically at `target`, which resolves to `oid`.
243    Attached {
244        target: FullName,
245        raw_target: String,
246        oid: ObjectId,
247    },
248    /// `HEAD` directly names an object id.
249    Detached { oid: ObjectId },
250}
251
252impl HeadState {
253    /// The immediate target stored in `HEAD`.
254    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    /// The symbolic target when `HEAD` is attached or unborn.
265    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    /// The resolved commit id when `HEAD` is attached to an existing branch or
273    /// detached.
274    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    /// Short branch name (`refs/heads/<name>` stripped to `<name>`), if any.
298    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/// Repository open behavior for callers that need to distinguish discovery from
306/// exact git-directory opening.
307#[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    /// When true, `path` must be the git directory itself; no parent discovery is
322    /// performed. When false, `path` is treated like [`Repository::discover`].
323    pub fn exact_path(mut self, exact_path: bool) -> Self {
324        self.exact_path = exact_path;
325        self
326    }
327
328    /// Require the opened repository to be bare.
329    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/// An ergonomic handle to a git repository.
350///
351/// Construct one with [`Repository::open`] (when you already know the git
352/// directory), [`Repository::discover`] (to search upward from a working-tree
353/// path), or [`Repository::init`] / [`Repository::init_bare`] (to create a new
354/// repository). The handle is cheap to clone and shares a session-scoped object
355/// database ([`Repository::objects`]) whose read caches survive across calls
356/// until [`Repository::refresh_objects`] (automatic after `fetch` / pack copy).
357#[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    /// Open the repository whose git directory is exactly `git_dir`.
377    ///
378    /// `git_dir` must be a git directory itself (the `.git` directory of a
379    /// non-bare repo, or the top level of a bare repo), not a working tree. Use
380    /// [`Repository::discover`] to search upward from a working-tree path.
381    ///
382    /// The path may be a `.git` *file* (a gitlink, as used by linked worktrees
383    /// and submodules); its `gitdir:` target is followed.
384    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    /// Open a repository with explicit discovery and bare-worktree policy.
396    ///
397    /// Use `OpenOptions::new().exact_path(true).bare(true)` for scratch bare
398    /// directories where silently discovering a parent checkout would be wrong.
399    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    /// Open exactly `git_dir` as a bare repository, never discovering a parent.
415    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    /// Discover the repository containing `path` by walking up the directory
420    /// tree, mirroring git's own discovery (`.git` directory, `.git` gitlink
421    /// file, or a bare repository at an ancestor).
422    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    /// Initialize a new non-bare repository rooted at `path` (creating its
428    /// `.git` directory) and return a handle to it. Re-initializing an existing
429    /// repository is a no-op on already-present files, matching `git init`.
430    pub fn init(path: impl AsRef<Path>) -> Result<Self> {
431        Self::init_with_format(path, ObjectFormat::Sha1, false)
432    }
433
434    /// Initialize a new bare repository at `path` (the directory becomes the git
435    /// directory itself) and return a handle to it.
436    pub fn init_bare(path: impl AsRef<Path>) -> Result<Self> {
437        Self::init_with_format(path, ObjectFormat::Sha1, true)
438    }
439
440    /// Initialize a new repository at `path` with an explicit object format and
441    /// bare-ness.
442    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    /// The repository's git directory (the `.git` directory of a non-bare repo,
464    /// or the top level of a bare repo).
465    pub fn git_dir(&self) -> &Path {
466        &self.git_dir
467    }
468
469    /// The common directory shared between linked worktrees. For a single
470    /// worktree this equals [`Repository::git_dir`]; for a linked worktree it is
471    /// the main repository's git directory (as recorded in `commondir`).
472    pub fn common_dir(&self) -> &Path {
473        &self.common_dir
474    }
475
476    /// The working-tree root, or `None` for a bare repository.
477    ///
478    /// Resolution follows git's repository-intrinsic rules: a `core.worktree`
479    /// override, then a linked worktree's recorded location, then the parent of
480    /// the `.git` directory for an ordinary checkout. It does *not* consult the
481    /// process-level `GIT_WORK_TREE`/`GIT_DIR` overrides (those belong to a CLI
482    /// front-end, not a library handle). A working tree that is configured but
483    /// cannot be resolved on disk (e.g. a `core.worktree` pointing at a missing
484    /// path) is reported as `None` rather than erroring.
485    pub fn workdir(&self) -> Option<PathBuf> {
486        sley_worktree::worktree_root_for_git_dir(&self.git_dir)
487            .ok()
488            .flatten()
489    }
490
491    /// Whether this repository is shallow — created or fetched with a depth
492    /// limit, so a `shallow` file records its grafted history boundaries.
493    pub fn is_shallow(&self) -> bool {
494        sley_worktree::is_shallow_repository(&self.git_dir)
495    }
496
497    /// Return short-status entries for this repository's working tree using
498    /// sley's default status options.
499    ///
500    /// Bare repositories have no working tree and return
501    /// [`GitError::Unsupported`].
502    pub fn short_status(&self) -> Result<Vec<ShortStatusEntry>> {
503        self.short_status_with_options(ShortStatusOptions::default())
504    }
505
506    /// Stream short-status entries for this repository's working tree using
507    /// sley's default status options.
508    ///
509    /// Bare repositories have no working tree and return
510    /// [`GitError::Unsupported`].
511    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    /// Return short-status entries for this repository's working tree.
519    ///
520    /// This facade collects entries from [`sley_worktree::stream_short_status_with_options`].
521    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    /// Stream short-status entries for this repository's working tree.
534    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    /// Count short-status entries for this repository's working tree.
555    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    /// Compare one tracked entry to this repository's worktree, using the same
568    /// racy-clean/stat-cache rules as [`Repository::short_status_with_options`].
569    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    /// The names of the configured remotes (`[remote "<name>"]` sections),
591    /// sorted alphabetically with duplicates collapsed — the order `git remote`
592    /// lists them in.
593    ///
594    /// Remotes are read from the *effective* configuration (see
595    /// [`Repository::config_snapshot`]), so a remote defined in an included file
596    /// is included.
597    pub fn remote_names(&self) -> Result<Vec<String>> {
598        Ok(sley_config::remotes::remote_names(&self.config_snapshot()?))
599    }
600
601    /// The repository's object format (`sha1` or `sha256`), read from
602    /// `extensions.objectformat`.
603    pub fn object_format(&self) -> ObjectFormat {
604        self.format
605    }
606
607    /// The reference store for this repository (loose refs, `packed-refs`, and
608    /// reflogs), scoped to this worktree's git directory.
609    pub fn references(&self) -> FileRefStore {
610        FileRefStore::new(self.git_dir.clone(), self.format)
611    }
612
613    /// The repository-level configuration (`<common_dir>/config`).
614    ///
615    /// Returns an empty [`GitConfig`] if the file is missing, matching the way
616    /// git treats an absent repository config.
617    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    /// The *effective* configuration, merging the system, global, and repository
627    /// config files in git's precedence order (repository wins, then global,
628    /// then system) with `include`/`includeIf` directives resolved.
629    ///
630    /// Unlike [`Repository::config`] (repository file only), this is what a
631    /// caller wanting git-equivalent lookups — e.g. resolving `user.name` from
632    /// `~/.gitconfig` — should use. File discovery honours `$GIT_CONFIG_SYSTEM`,
633    /// `$GIT_CONFIG_GLOBAL`, `$XDG_CONFIG_HOME`, `$HOME`, and
634    /// `$GIT_CONFIG_NOSYSTEM` exactly as git does; missing files are skipped.
635    ///
636    /// This does not layer in `-c`/`GIT_CONFIG_COUNT` command-line overrides,
637    /// which are process-level and higher precedence than any file.
638    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    /// Look up `section.key` in the effective config (see
647    /// [`Repository::config_snapshot`]), returning the highest-precedence value
648    /// or `None` if unset. For a subsectioned key use
649    /// [`Repository::config_string_subsection`].
650    pub fn config_string(&self, section: &str, key: &str) -> Result<Option<String>> {
651        self.config_string_subsection(section, None, key)
652    }
653
654    /// Look up `section.subsection.key` in the effective config, honouring
655    /// subsections (e.g. `remote.origin.url`). `subsection` of `None` reads the
656    /// bare section.
657    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    /// Absolute common git directory used as the `gitdir:` context for
668    /// `includeIf` evaluation, falling back to the unmodified path when it
669    /// cannot be canonicalised (e.g. it does not yet exist).
670    fn config_include_git_dir(&self) -> PathBuf {
671        std::fs::canonicalize(&self.common_dir).unwrap_or_else(|_| self.common_dir.clone())
672    }
673
674    /// Short branch name from `HEAD` for `includeIf "onbranch:"` evaluation, or
675    /// `None` when detached/unborn. Errors are swallowed: a config snapshot must
676    /// not fail just because `HEAD` is unreadable.
677    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    /// Resolve `HEAD`: its symbolic branch target (if any) and the commit it
687    /// points at (if the branch exists).
688    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    /// Inspect `HEAD` without collapsing attached, detached, unborn, and missing
707    /// states into `Option` fields.
708    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    /// Look up a reference by full name (e.g. `refs/heads/main`, `refs/tags/v1`,
730    /// or `HEAD`), returning `None` if it does not exist.
731    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    /// Return whether `name` exists in the ref backend without resolving its
740    /// target or reading the object it names.
741    pub fn reference_exists(&self, name: &str) -> Result<bool> {
742        self.references().raw_ref_exists(name)
743    }
744
745    /// Look up a reference that must exist.
746    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    /// Peel annotated tags until the referenced non-tag object is reached.
752    ///
753    /// This does not resolve symbolic refs. Use [`Reference::direct_target`] or
754    /// [`Repository::head`] first so the call site is explicit about symbolic
755    /// reference resolution versus object graph peeling.
756    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(&current).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    /// Peel an object id to the commit it ultimately names.
780    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    /// Resolve a revision specification (anything `git rev-parse` accepts:
792    /// branch/tag names, abbreviated or full object ids, `HEAD~2`, `<rev>:<path>`,
793    /// `@{u}`, etc.) to a concrete [`ObjectId`].
794    pub fn rev_parse(&self, spec: &str) -> Result<ObjectId> {
795        sley_rev::resolve_revision(&self.git_dir, self.format, spec)
796    }
797
798    /// Resolve `<rev>:<path>` to the tree entry it names within `<rev>`'s tree.
799    ///
800    /// `rev` is peeled to a tree (commit, tag, or tree ids all work) and `path`
801    /// is walked component by component. An empty `path` resolves to the tree
802    /// itself.
803    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    /// Write an annotated tag object, returning its id.
814    ///
815    /// This creates only the tag *object*; updating `refs/tags/<name>` is the
816    /// caller's responsibility (see [`Repository::apply_ref_changes`]).
817    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    /// Copy objects reachable from `roots` out of `other` into this repository.
823    ///
824    /// Uses a pack-based transfer ([`sley_odb::build_reachable_pack`] on the
825    /// source, [`sley_odb::install_raw_pack`] on the destination) for
826    /// performance. Semantics:
827    ///
828    /// * Only *objects* are copied; refs in `other` are not updated here.
829    /// * The transitive closure of each root is included (commits bring in
830    ///   their trees, blobs, tags, and parent commits).
831    /// * Objects already present in this repository are skipped by the pack
832    ///   installer (ids are unchanged).
833    /// * Both repositories must use the same [`ObjectFormat`]; mismatches error.
834    /// * When nothing new is reachable, this is a no-op (`Ok(())`).
835    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    /// Read a raw object (any type) from the object database.
854    pub fn read_object(&self, oid: &ObjectId) -> Result<Arc<EncodedObject>> {
855        ObjectReader::read_object(self.objects.as_ref(), oid)
856    }
857
858    /// Read a commit object, parsing it into a [`Commit`]. Returns an error if
859    /// `oid` does not name a commit.
860    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    /// Read a tree object, parsing it into a [`Tree`]. Returns an error if `oid`
879    /// does not name a tree.
880    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    /// Read an annotated tag object, parsing it into a [`Tag`]. Returns an error
899    /// if `oid` does not name a tag.
900    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    /// Read a commit and return the typed [`Signature`] parse-view of its
919    /// `author` line, or `None` if the stored ident is malformed.
920    ///
921    /// Convenience for `repo.read_commit(oid)?.author_signature()`: the raw
922    /// `author` bytes are unchanged, and the returned signature re-serializes
923    /// byte-identically to them (see [`Signature::to_ident_bytes`]).
924    pub fn read_commit_author(&self, oid: &ObjectId) -> Result<Option<Signature>> {
925        Ok(self.read_commit(oid)?.author_signature())
926    }
927
928    /// Read a commit and return the typed [`Signature`] parse-view of its
929    /// `committer` line, or `None` if the stored ident is malformed. The raw
930    /// bytes are untouched; see [`Repository::read_commit_author`].
931    pub fn read_commit_committer(&self, oid: &ObjectId) -> Result<Option<Signature>> {
932        Ok(self.read_commit(oid)?.committer_signature())
933    }
934
935    /// Read an annotated tag and return the typed [`Signature`] parse-view of
936    /// its `tagger` line, or `None` if the tag has no tagger or the stored ident
937    /// is malformed. The raw bytes are untouched; see
938    /// [`Repository::read_commit_author`].
939    pub fn read_tag_tagger(&self, oid: &ObjectId) -> Result<Option<Signature>> {
940        Ok(self.read_tag(oid)?.tagger_signature())
941    }
942
943    /// Write a raw object (any type) to the object database as a loose object,
944    /// returning its id. The bytes are stored verbatim, so writing an object
945    /// that originated from another repository preserves its id exactly.
946    pub fn write_object(&self, object: EncodedObject) -> Result<ObjectId> {
947        let odb = self.objects_mut();
948        odb.write_object(object)
949    }
950
951    /// Write `body` as a raw object of `object_type`, preserving the bytes
952    /// exactly and returning the resulting object id.
953    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    /// Write `bytes` as a blob, returning its id.
962    pub fn write_blob(&self, bytes: impl Into<Vec<u8>>) -> Result<ObjectId> {
963        self.write_object(EncodedObject::new(ObjectType::Blob, bytes))
964    }
965
966    /// Start editing the tree `base` one level deep: returns a [`TreeBuilder`]
967    /// seeded with `base`'s entries (empty when `base` is the null or empty
968    /// tree). `upsert` entries on it, then write it with
969    /// [`Repository::write_tree`].
970    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    /// Write the tree assembled in `builder` — entries emitted in Git's
978    /// canonical order — and return its id.
979    pub fn write_tree(&self, builder: TreeBuilder) -> Result<ObjectId> {
980        self.write_object(EncodedObject::new(ObjectType::Tree, builder.write()))
981    }
982
983    /// Read this repository's index (`.git/index`), returning `None` when the
984    /// index file does not exist yet.
985    pub fn open_index(&self) -> Result<Option<Index>> {
986        sley_worktree::read_repository_index(&self.git_dir, self.format)
987    }
988
989    /// Build a fresh index mirroring `tree_oid` (stage-0 entries with a zeroed
990    /// stat), the way `git read-tree <tree>` would. Does not touch `.git/index`.
991    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    /// Follow a symbolic ref chain (e.g. `HEAD` -> `refs/heads/main`) to the
996    /// final object id, returning `None` if the chain ends at a ref that does
997    /// not exist (an unborn branch).
998    fn resolve_symbolic(&self, name: &str) -> Result<Option<ObjectId>> {
999        let refs = self.references();
1000        // Git refuses to follow symref chains deeper than five hops; mirror that
1001        // bound so a cycle cannot loop forever.
1002        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(&current)? {
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
1031/// Read the object format recorded in a git directory's config, defaulting to
1032/// SHA-1 (as git does) when the config is absent or omits the extension.
1033fn 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
1042/// Resolve a path that may be either a git directory or a `.git` gitlink file to
1043/// the real git directory.
1044fn 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
1053/// True if `path` looks like a git directory (has a `HEAD` file and either an
1054/// `objects` directory or a `commondir` pointer).
1055fn 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
1060/// Read a `gitdir: <path>` link file (used by linked worktrees and submodules),
1061/// returning the absolute target path it points at.
1062fn 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
1076/// Walk up from `start` looking for a repository, mirroring git's discovery
1077/// rules: a `.git` directory, a `.git` gitlink file, or a bare repository.
1078fn 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    /// A temporary directory that cleans itself up on drop.
1121    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    /// Write a blob, a tree referencing it, and a commit pointing at the tree,
1148    /// then point `refs/heads/main` at the commit. Returns the commit oid.
1149    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        // Re-open via open() on the .git directory.
1244        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        // Bare repo: the path itself is the git dir, no nested .git.
1254        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        // Raw object read.
1467        let raw = repo.read_object(&commit_oid).expect("read object");
1468        assert_eq!(raw.object_type, ObjectType::Commit);
1469
1470        // Typed commit read.
1471        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        // Typed tree read via the commit's tree.
1476        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        // The blob under the tree is readable as a raw object.
1481        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        // The tree oid is not a commit.
1566        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        // Full hex object id also resolves.
1585        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        // Peeling HEAD follows the symbolic chain to the commit.
1611        assert_eq!(head.peeled_oid(&repo).expect("peel head"), Some(commit_oid));
1612
1613        // A missing ref returns None rather than erroring.
1614        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        // Both should point at the same .git after canonicalization.
1630        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        // temp dir is not inside a repo (it lives directly under the system tmp
1640        // dir, which is not a git working tree).
1641        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        // init writes core.repositoryformatversion and core.bare.
1659        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        // Re-open and confirm the format is read back from config.
1674        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        // Append identity + a subsectioned remote to the repository config. The
1687        // repository layer is the highest-precedence file layer, so these win
1688        // over any global/system config the test machine might have, keeping the
1689        // assertions hermetic. (End-to-end global fallback is covered by the
1690        // CLI's subprocess interop test.)
1691        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        // config_snapshot returns the merged effective config.
1700        let snapshot = repo.config_snapshot().expect("snapshot");
1701        assert_eq!(snapshot.get("user", None, "name"), Some("Snapshot Person"));
1702
1703        // config_string is the convenience wrapper.
1704        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        // Subsections are honoured.
1718        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        // The non-bare layout's working tree is the parent of `.git`, returned
1730        // verbatim (so it matches the path init was handed).
1731        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        // Point core.worktree at a real sibling directory.
1746        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        // The override wins and is canonicalised.
1756        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        // Define remotes out of alphabetical order and with a duplicate section.
1780        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        // Smoke test that the re-exports compile and resolve to the right types.
1796        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}