Skip to main content

git_remote_object_store/
git.rs

1//! Native git operations layered on top of [`gix`][gix].
2//!
3//! Operations that `gix` 0.82 exposes go through `gix` natively; config
4//! reads/writes go through `gix-config` + `gix-lock` for atomic edits
5//! parity with `git config`. Bundle creation and consumption use the
6//! native `gix-pack`-based implementation in [`crate::bundle`]; no
7//! `git` subprocess is spawned at runtime.
8//!
9//! [gix]: https://docs.rs/gix
10
11use std::collections::{HashSet, VecDeque};
12use std::fmt;
13use std::io;
14use std::io::Write as _;
15use std::num::NonZeroU32;
16use std::path::{Path, PathBuf};
17use std::string::FromUtf8Error;
18use std::sync::atomic::AtomicBool;
19
20pub(crate) mod branch;
21
22use gix::Repository;
23use gix::bstr::{BStr, ByteSlice};
24use gix::config::file::Metadata as GixConfigMetadata;
25use gix::config::file::init as gix_config_init;
26use gix::config::parse::section::{
27    ValueName, header as gix_section_header, value_name as gix_value_name,
28};
29use gix::lock as gix_lock;
30use gix::progress::Discard;
31use gix::remote::Direction;
32use gix_hash::ObjectId;
33use thiserror::Error;
34use tracing::debug;
35
36/// SHA-1 object OID, displayed as 40 lowercase hex characters.
37///
38/// Wraps [`gix_hash::ObjectId`] to make the wire-format invariant —
39/// lowercase-hex bundle filenames on the bucket — a type-system
40/// property. The wrapper is agnostic to object kind: instances may
41/// hold the OID of a commit, annotated tag, tree, or blob. Callers
42/// that require a commit specifically must peel via
43/// [`peel_tag_chain`] and match on [`PeeledTip`].
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub struct Sha(ObjectId);
46
47impl Sha {
48    /// Parse a SHA-1 hex string. Accepts lowercase, uppercase, or mixed
49    /// case input and stores it canonically; [`Display`][fmt::Display]
50    /// always emits lowercase.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`ShaError::Empty`] if `hex` is empty, or
55    /// [`ShaError::Decode`] if the input is the wrong length or contains
56    /// non-hex characters.
57    pub fn from_hex(hex: &str) -> Result<Self, ShaError> {
58        if hex.is_empty() {
59            return Err(ShaError::Empty);
60        }
61        Ok(Sha(ObjectId::from_hex(hex.as_bytes())?))
62    }
63
64    /// Wrap an existing [`ObjectId`] without re-validating.
65    #[must_use]
66    pub fn from_object_id(id: ObjectId) -> Self {
67        Sha(id)
68    }
69
70    /// Borrow the underlying [`ObjectId`].
71    #[must_use]
72    pub fn as_object_id(&self) -> &ObjectId {
73        &self.0
74    }
75}
76
77impl fmt::Display for Sha {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        self.0.fmt(f)
80    }
81}
82
83/// Error returned by [`Sha::from_hex`].
84#[derive(Debug, Error)]
85pub enum ShaError {
86    /// Input was the empty string.
87    #[error("expected hex digits, got empty string")]
88    Empty,
89    /// Input was the wrong length or contained non-hex characters.
90    #[error(transparent)]
91    Decode(#[from] gix_hash::decode::Error),
92}
93
94/// Validated git ref name — guaranteed to satisfy
95/// `gix_validate::reference::name` (the strict, fully-qualified form).
96#[derive(Debug, Clone, PartialEq, Eq, Hash)]
97pub struct RefName(String);
98
99impl RefName {
100    /// Validate `name` and wrap it.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`RefNameError::Invalid`] for any name that
105    /// `gix-validate` would reject.
106    pub fn new(name: impl Into<String>) -> Result<Self, RefNameError> {
107        let name = name.into();
108        match gix_validate::reference::name(BStr::new(&name)) {
109            Ok(_) => Ok(RefName(name)),
110            Err(source) => Err(RefNameError::Invalid { name, source }),
111        }
112    }
113
114    /// Borrow as a plain `&str`.
115    #[must_use]
116    pub fn as_str(&self) -> &str {
117        &self.0
118    }
119
120    /// `true` iff `name` would be accepted by [`RefName::new`]. A
121    /// borrow-only predicate for callers that just need the validity
122    /// check without keeping the wrapped value — avoids the `String`
123    /// allocation [`new`](Self::new) performs on its `impl Into<String>`
124    /// argument.
125    #[must_use]
126    pub fn is_valid(name: &str) -> bool {
127        gix_validate::reference::name(BStr::new(name)).is_ok()
128    }
129}
130
131impl fmt::Display for RefName {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.write_str(&self.0)
134    }
135}
136
137impl AsRef<str> for RefName {
138    fn as_ref(&self) -> &str {
139        &self.0
140    }
141}
142
143impl From<RefName> for String {
144    fn from(value: RefName) -> Self {
145        value.0
146    }
147}
148
149/// Error returned by [`RefName::new`].
150#[derive(Debug, Error)]
151pub enum RefNameError {
152    /// `gix-validate` rejected the input.
153    #[error("invalid ref name {name:?}: {source}")]
154    Invalid {
155        /// The rejected input.
156        name: String,
157        /// The underlying gix-validate error.
158        #[source]
159        source: gix_validate::reference::name::Error,
160    },
161}
162
163/// Permissive ref-name predicate.
164///
165/// Returns `true` iff `name` passes `gix_validate::reference::name_partial`.
166/// The partial form accepts single-component names like `HEAD`; for the
167/// strict, fully-qualified form used when constructing a [`RefName`], use
168/// [`RefName::new`] instead.
169#[must_use]
170pub fn is_valid_ref_name(name: &str) -> bool {
171    gix_validate::reference::name_partial(BStr::new(name)).is_ok()
172}
173
174/// Aggregate error for the helpers in this module.
175#[derive(Debug, Error)]
176pub enum GitError {
177    /// Caller passed an empty rev-spec.
178    #[error("rev-spec is empty")]
179    EmptySpec,
180    /// `head_commit()` was called on a repository with no commits.
181    #[error("repository has no commits")]
182    NoCommits,
183    /// Named remote does not exist.
184    #[error("remote not found: {0}")]
185    RemoteNotFound(String),
186    /// Remote exists but has neither a fetch nor a push URL.
187    #[error("remote has no fetch or push URL: {0}")]
188    RemoteHasNoUrl(String),
189    /// Remote URL is not valid UTF-8.
190    #[error("remote {remote} URL is not valid UTF-8")]
191    NonUtf8RemoteUrl {
192        /// The remote whose URL could not be decoded.
193        remote: String,
194        /// The underlying decode error.
195        #[source]
196        source: FromUtf8Error,
197    },
198    /// Native bundle operation failed.
199    #[error("bundle: {0}")]
200    Bundle(Box<crate::bundle::BundleError>),
201    /// A `spawn_blocking` task panicked.
202    #[error("blocking task panicked")]
203    Panic(#[from] tokio::task::JoinError),
204    /// Local I/O error.
205    #[error(transparent)]
206    Io(#[from] io::Error),
207    /// `rev_parse_single` failed.
208    #[error(transparent)]
209    RevParse(#[from] gix::revision::spec::parse::single::Error),
210    /// Could not find an object referenced from a rev-spec.
211    #[error(transparent)]
212    FindObject(#[from] gix::object::find::existing::Error),
213    /// Could not peel an object to the requested kind.
214    #[error(transparent)]
215    PeelToKind(#[from] gix::object::peel::to_kind::Error),
216    /// `head_commit()` failed.
217    #[error(transparent)]
218    HeadCommit(#[from] gix::reference::head_commit::Error),
219    /// Could not decode commit object.
220    #[error(transparent)]
221    DecodeCommit(#[from] gix::objs::decode::Error),
222    /// Computing a short id failed.
223    #[error(transparent)]
224    ShortId(#[from] gix::id::shorten::Error),
225    /// Underlying merge-base computation failed.
226    #[error(transparent)]
227    MergeBase(Box<gix::repository::merge_base::Error>),
228    /// Building the worktree stream for archive emission failed.
229    #[error(transparent)]
230    WorktreeStream(Box<gix::repository::worktree_stream::Error>),
231    /// Writing the archive to disk failed.
232    #[error(transparent)]
233    WorktreeArchive(Box<gix::repository::worktree_archive::Error>),
234    /// `find_remote()` failed.
235    #[error(transparent)]
236    FindRemote(Box<gix::remote::find::existing::Error>),
237    /// `gix::open()` failed.
238    #[error(transparent)]
239    Open(Box<gix::open::Error>),
240    /// `gix::discover()` failed when locating the config file.
241    #[error(transparent)]
242    Discover(Box<gix::discover::Error>),
243    /// Dotted config key was empty, contained empty segments, or had no `.`.
244    #[error("invalid config key {0:?}: must be of the form <section>[.<subsection>].<name>")]
245    ConfigKeyParse(String),
246    /// `gix-config` rejected a section header (invalid name characters).
247    #[error("invalid config section name {name:?}: {source}")]
248    ConfigInvalidSectionName {
249        /// The rejected section name.
250        name: String,
251        /// Underlying validator error.
252        #[source]
253        source: gix_section_header::Error,
254    },
255    /// `gix-config` rejected a value name (invalid characters or non-alphabetic start).
256    #[error("invalid config value name {name:?}: {source}")]
257    ConfigInvalidValueName {
258        /// The rejected value name.
259        name: String,
260        /// Underlying validator error.
261        #[source]
262        source: gix_value_name::Error,
263    },
264    /// `--unset` was issued for a key that is not present in the local config.
265    #[error("config key not set: {0}")]
266    ConfigKeyNotSet(String),
267    /// Failed to parse the existing `.git/config` file.
268    #[error(transparent)]
269    ConfigParse(Box<gix_config_init::Error>),
270    /// Failed to acquire a lock file for an atomic file write (e.g.
271    /// `.git/config.lock`, `.git/shallow.lock`).
272    #[error(transparent)]
273    ConfigLock(Box<gix_lock::acquire::Error>),
274    /// A tag chain visited the same OID twice — i.e. a cycle. Real git
275    /// objects cannot form cycles (each tag's OID is determined by the
276    /// SHA-1 of its content, which includes the target OID, so a cycle
277    /// would require a SHA-1 preimage). This guard exists for adversarial
278    /// or corrupted ODB inputs that bypass the hashing invariant.
279    #[error("tag chain contains a cycle at {oid}")]
280    TagChainCycle {
281        /// The OID at which the cycle was detected.
282        oid: ObjectId,
283    },
284}
285
286impl From<gix::open::Error> for GitError {
287    fn from(e: gix::open::Error) -> Self {
288        GitError::Open(Box::new(e))
289    }
290}
291
292impl From<gix::repository::merge_base::Error> for GitError {
293    fn from(e: gix::repository::merge_base::Error) -> Self {
294        GitError::MergeBase(Box::new(e))
295    }
296}
297
298impl From<gix::repository::worktree_stream::Error> for GitError {
299    fn from(e: gix::repository::worktree_stream::Error) -> Self {
300        GitError::WorktreeStream(Box::new(e))
301    }
302}
303
304impl From<gix::repository::worktree_archive::Error> for GitError {
305    fn from(e: gix::repository::worktree_archive::Error) -> Self {
306        GitError::WorktreeArchive(Box::new(e))
307    }
308}
309
310impl From<gix::remote::find::existing::Error> for GitError {
311    fn from(e: gix::remote::find::existing::Error) -> Self {
312        GitError::FindRemote(Box::new(e))
313    }
314}
315
316impl From<gix::discover::Error> for GitError {
317    fn from(e: gix::discover::Error) -> Self {
318        GitError::Discover(Box::new(e))
319    }
320}
321
322impl From<gix_config_init::Error> for GitError {
323    fn from(e: gix_config_init::Error) -> Self {
324        GitError::ConfigParse(Box::new(e))
325    }
326}
327
328impl From<gix_lock::acquire::Error> for GitError {
329    fn from(e: gix_lock::acquire::Error) -> Self {
330        GitError::ConfigLock(Box::new(e))
331    }
332}
333
334/// Pick a working directory for git operations targeting `repo`. Prefers
335/// the work tree and falls back to the git directory for bare repositories.
336fn repo_cwd(repo: &Repository) -> &Path {
337    repo.workdir().unwrap_or_else(|| repo.git_dir())
338}
339
340/// Write a git bundle for `spec` to `<folder>/<sha>.bundle` and return
341/// the absolute path.
342///
343/// `spec` is a rev-spec — a fully-qualified ref (`refs/heads/main`), a
344/// short branch (`main`), `HEAD`, or a SHA. All objects reachable from
345/// the resolved commit are included.
346///
347/// The returned future is **not** `Send`: `gix::Repository` is `!Sync`,
348/// so the captured `&Repository` parameter cannot cross thread
349/// boundaries. Callers must `.await` it directly rather than passing
350/// it to `tokio::spawn`.
351///
352/// # Errors
353///
354/// Returns [`GitError::Bundle`] if the spec cannot be resolved, the
355/// commit graph cannot be walked, or the bundle file cannot be written.
356/// Returns [`GitError::Panic`] if the blocking task panics.
357pub async fn bundle(
358    repo: &Repository,
359    folder: &Path,
360    sha: Sha,
361    spec: &str,
362) -> Result<PathBuf, GitError> {
363    // `&Repository` is !Send (Repository is Send but !Sync), so we must
364    // not hold a `&Path` borrowed from `repo` across the .await.
365    let cwd = repo_cwd(repo).to_owned();
366    bundle_at(&cwd, folder, sha, spec).await
367}
368
369/// Path-only variant of [`bundle`] for callers that cannot hold a
370/// `&Repository` across `.await` (the protocol push handler shares
371/// state across tokio tasks; `gix::Repository` is `!Sync`, so its
372/// future would not be `Send`).
373///
374/// # Errors
375///
376/// Returns [`GitError::Bundle`] if the spec cannot be resolved, the
377/// commit graph cannot be walked, or the bundle file cannot be written.
378/// Returns [`GitError::Panic`] if the blocking task panics.
379pub async fn bundle_at(
380    cwd: &Path,
381    folder: &Path,
382    sha: Sha,
383    spec: &str,
384) -> Result<PathBuf, GitError> {
385    let (cwd, folder, spec) = (cwd.to_owned(), folder.to_owned(), spec.to_owned());
386    tokio::task::spawn_blocking(move || crate::bundle::create(&cwd, &folder, sha, &spec))
387        .await?
388        .map_err(|e| GitError::Bundle(Box::new(e)))
389}
390
391/// Unbundle `<folder>/<sha>.bundle` into `repo`.
392///
393/// Objects are installed into the ODB; no ref is created. Ref creation
394/// is the remote-helper protocol's responsibility.
395///
396/// # Errors
397///
398/// Returns [`GitError::Bundle`] if the bundle file is malformed,
399/// prerequisite objects are missing, or the pack cannot be installed.
400/// Returns [`GitError::Panic`] if the blocking task panics.
401pub async fn unbundle(repo: &Repository, folder: &Path, sha: Sha) -> Result<(), GitError> {
402    unbundle_at(repo_cwd(repo), folder, sha).await
403}
404
405/// Path-only variant of [`unbundle`] for callers that cannot hold a
406/// `&Repository` across `.await` (notably the parallel fetch handler:
407/// `gix::Repository` is `!Sync`, so it cannot be shared across
408/// concurrent tasks).
409///
410/// # Errors
411///
412/// Returns [`GitError::Bundle`] if the bundle file is malformed,
413/// prerequisite objects are missing, or the pack cannot be installed.
414/// Returns [`GitError::Panic`] if the blocking task panics.
415pub async fn unbundle_at(cwd: &Path, folder: &Path, sha: Sha) -> Result<(), GitError> {
416    let (cwd, folder) = (cwd.to_owned(), folder.to_owned());
417    tokio::task::spawn_blocking(move || crate::bundle::unbundle(&cwd, &folder, sha))
418        .await?
419        .map_err(|e| GitError::Bundle(Box::new(e)))
420}
421
422/// Return `true` iff `ancestor` is an ancestor of `descendant` (or
423/// equals it).
424///
425/// Uses the `merge_base(A, B) == A` identity. A commit is its own
426/// ancestor; unrelated commits return `false`; missing commits propagate
427/// as `GitError`.
428///
429/// # Errors
430///
431/// Returns [`GitError::MergeBase`] if the merge-base computation fails.
432pub fn is_ancestor(repo: &Repository, ancestor: Sha, descendant: Sha) -> Result<bool, GitError> {
433    if ancestor == descendant {
434        return Ok(true);
435    }
436    let ancestor_oid = *ancestor.as_object_id();
437    let descendant_oid = *descendant.as_object_id();
438    match repo.merge_base(ancestor_oid, descendant_oid) {
439        Ok(base) => Ok(base.detach() == ancestor_oid),
440        Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(false),
441        Err(e) => Err(e.into()),
442    }
443}
444
445/// Result of peeling a ref's target through any annotated-tag chain.
446///
447/// The variant tells the caller what kind of leaf object the chain
448/// terminates at; `tag_chain` is the ordered sequence of tag-object OIDs
449/// encountered along the way (newest-first, i.e. outer then inner).
450/// `tag_chain` is empty for branch / lightweight-tag pushes and for bare
451/// non-tag refs that point directly at a tree or blob.
452///
453/// Used by both pack engines: the tag objects themselves are appended to
454/// the emitted pack so a receiver can install the full chain, and the
455/// leaf-kind variant decides whether the pack is built from a commit
456/// rev-walk, a tree closure, or a single blob.
457pub(crate) enum PeeledTip {
458    /// Chain terminates at a commit — the canonical case (branch tips,
459    /// lightweight tags, annotated tags of commits).
460    Commit {
461        commit: Sha,
462        tag_chain: Vec<ObjectId>,
463    },
464    /// Chain terminates at a tree (annotated tag of tree, or a bare ref
465    /// pointing at a tree). The pack carries the tree plus its full
466    /// recursive subtree + blob closure verbatim, no rev-walk.
467    Tree {
468        tree: ObjectId,
469        tag_chain: Vec<ObjectId>,
470    },
471    /// Chain terminates at a blob (annotated tag of blob, or a bare ref
472    /// pointing at a blob). The pack carries the blob plus the tag
473    /// chain — there is no tree to walk.
474    Blob {
475        blob: ObjectId,
476        tag_chain: Vec<ObjectId>,
477    },
478}
479
480/// Peel `tip` through any annotated-tag chain to its leaf object.
481///
482/// Returns a [`PeeledTip`] whose variant identifies the leaf kind
483/// (commit / tree / blob) and whose `tag_chain` lists the tag objects
484/// encountered in walk order (outer first, inner last). For a branch
485/// tip or lightweight tag the chain is empty and the variant is
486/// `Commit`.
487///
488/// Both pack engines call this so the tag objects themselves land in
489/// the emitted pack — without them a receiver could install all
490/// reachable objects yet still fail to update `refs/tags/v1` because
491/// the tag-OID it must point at is not in the ODB.
492///
493/// # Errors
494///
495/// - [`GitError::FindObject`] if `tip` or any intermediate tag's
496///   target is missing from the ODB.
497/// - [`GitError::PeelToKind`] if a tag object's bytes do not decode.
498/// - [`GitError::TagChainCycle`] if the chain visits the same OID
499///   twice (corrupted or adversarial ODB only — real git tags cannot
500///   cycle).
501pub(crate) fn peel_tag_chain(repo: &Repository, tip: Sha) -> Result<PeeledTip, GitError> {
502    // `visited` defends against cyclic chains in a corrupted or
503    // adversarial ODB. Real git tags cannot cycle (a cycle would
504    // require a SHA-1 preimage), so the HashSet stays at length ≤ chain
505    // depth, which is typically 0–2 in practice.
506    let mut visited: HashSet<ObjectId> = HashSet::new();
507    let mut tag_chain = Vec::new();
508    let mut current = *tip.as_object_id();
509    loop {
510        if !visited.insert(current) {
511            return Err(GitError::TagChainCycle { oid: current });
512        }
513        let object = repo.find_object(current)?;
514        match object.kind {
515            gix::object::Kind::Commit => {
516                return Ok(PeeledTip::Commit {
517                    commit: Sha::from_object_id(current),
518                    tag_chain,
519                });
520            }
521            gix::object::Kind::Tag => {
522                tag_chain.push(current);
523                current = object.into_tag().target_id()?.detach();
524            }
525            gix::object::Kind::Tree => {
526                return Ok(PeeledTip::Tree {
527                    tree: current,
528                    tag_chain,
529                });
530            }
531            gix::object::Kind::Blob => {
532                return Ok(PeeledTip::Blob {
533                    blob: current,
534                    tag_chain,
535                });
536            }
537        }
538    }
539}
540
541/// Compute the shallow-fetch boundary commits for `tip` at `max_depth`.
542///
543/// Performs a breadth-first walk from `tip`. The returned vector contains
544/// the **frontier** OIDs — commits reached at exactly `max_depth`. Git
545/// writes these to `.git/shallow` so they appear parentless, giving
546/// exactly `max_depth` visible commits from `tip`.
547///
548/// BFS is mandatory here: `gix::Repository::rev_walk` returns commits in
549/// topological-sort order, which does not coincide with depth order at
550/// merge points. Naively `.take(N)` on the walk would include the wrong
551/// commits and emit incorrect boundaries.
552///
553/// If the walk exhausts the graph before reaching `max_depth` (i.e. the
554/// repository's history is shorter than the requested depth) the
555/// returned vector is empty — the repo is fully cloned and no shallow
556/// marker should be written.
557///
558/// **Non-commit tips**: shallow only applies to commit history. For a
559/// tag-of-tree / tag-of-blob / bare-tree / bare-blob ref the leaf has
560/// no parents in the commit graph and `--depth=N` has no semantic
561/// meaning; the function logs a warning and returns an empty vector
562/// (the outer fetch loop then installs everything, equivalent to a
563/// full fetch with no shallow markers).
564///
565/// # Errors
566///
567/// Returns [`GitError::FindObject`] if `tip` or any of its ancestors
568/// cannot be located in the local object database (the bundle was not
569/// installed correctly), or [`GitError::PeelToKind`] if an object that
570/// is supposed to be a commit cannot be decoded as one.
571pub(crate) fn shallow_boundaries(
572    repo: &Repository,
573    tip: Sha,
574    max_depth: NonZeroU32,
575) -> Result<Vec<ObjectId>, GitError> {
576    let max_depth = max_depth.get();
577    // Peel through any annotated-tag chain. Shallow has no meaning for
578    // tree- or blob-tipped refs; short-circuit to "no boundaries" so
579    // the outer fetch loop falls back to installing the full chain.
580    let tip_oid = match peel_tag_chain(repo, tip)? {
581        PeeledTip::Commit { commit, .. } => *commit.as_object_id(),
582        PeeledTip::Tree { .. } | PeeledTip::Blob { .. } => {
583            tracing::warn!(
584                tip = %tip,
585                "shallow fetch (--depth=N) is not meaningful for non-commit-tipped refs; \
586                 falling back to full fetch (no .git/shallow markers written)",
587            );
588            return Ok(Vec::new());
589        }
590    };
591
592    // BFS from `tip`. `seen` deduplicates; `frontier` accumulates the
593    // commits at exactly max_depth — the boundary written to .git/shallow.
594    let mut seen: HashSet<ObjectId> = HashSet::new();
595    let mut frontier: Vec<ObjectId> = Vec::new();
596    let mut queue: VecDeque<(ObjectId, u32)> = VecDeque::new();
597    queue.push_back((tip_oid, 1));
598
599    while let Some((oid, depth)) = queue.pop_front() {
600        if !seen.insert(oid) {
601            continue;
602        }
603        if depth == max_depth {
604            // Frontier commit: appears parentless in the shallow clone.
605            // Do not recurse further — its parents are excluded.
606            frontier.push(oid);
607            continue;
608        }
609        let commit = repo
610            .find_object(oid)?
611            .peel_to_kind(gix::object::Kind::Commit)?;
612        let commit = commit.into_commit();
613        for parent in commit.parent_ids() {
614            let parent_oid = parent.detach();
615            if !seen.contains(&parent_oid) {
616                queue.push_back((parent_oid, depth + 1));
617            }
618        }
619    }
620
621    Ok(frontier)
622}
623
624/// 40 hex digits + '\n' per `.git/shallow` entry.
625const SHA1_HEX_LINE_LEN: usize = 41;
626
627/// Rewrite `<git_dir>/shallow` so that it lists exactly the commits that
628/// remain shallow boundaries — `boundaries` plus any pre-existing entry
629/// whose parents are still missing from the local ODB.
630///
631/// `repo_dir` is the working-tree root (or the git directory itself for a
632/// bare repo); the actual `.git/shallow` location is derived internally
633/// to handle linked-worktree and `--separate-git-dir` layouts.
634///
635/// A shallow boundary is a commit whose parents are not present locally;
636/// git's `shallow.c::register_shallow` grafts every entry in
637/// `.git/shallow` to be parentless (and frees the in-memory parent
638/// pointers), so a stale entry suppresses newly-installed parents. After
639/// a deepening fetch the previous boundary's parents land in the ODB
640/// and the entry must be dropped, otherwise `git log` still stops at the
641/// old shallow tip even though deeper history is reachable.
642///
643/// Algorithm:
644/// 1. Pre-existing entries that are *also* in `boundaries` are kept
645///    unconditionally (the new fetch explicitly designated them).
646/// 2. Each remaining pre-existing entry is dropped iff every parent is
647///    present in `repo`'s ODB; an octopus-merge entry stays as long as
648///    *any* parent is still missing. Entries pointing at a missing or
649///    non-commit object are also dropped (stale).
650/// 3. If the resulting set is empty, `.git/shallow` is unlinked when
651///    present — a fully-deepened repository must not retain the file
652///    (matches git's own behaviour in `shallow.c::prune_shallow`).
653///
654/// The file format is one SHA-1 hex per line, sorted for stable output;
655/// the existing parser is lenient (skips blank or malformed lines) so
656/// external tooling's annotations do not break the read pass.
657///
658/// # Errors
659///
660/// Returns [`GitError::Open`] if `repo_dir` cannot be opened as a gix
661/// repository, [`GitError::Io`] if the file cannot be read, written, or
662/// unlinked, or [`GitError::ConfigLock`] if the lock file cannot be
663/// acquired.
664pub(crate) fn write_shallow_file(repo_dir: &Path, boundaries: &[ObjectId]) -> Result<(), GitError> {
665    let path = git_dir_for(repo_dir).join("shallow");
666
667    // Read existing entries leniently: skip blank lines and content that
668    // isn't a 40-hex SHA so external annotations or stray whitespace do
669    // not abort the rewrite.
670    let mut existing: HashSet<ObjectId> = HashSet::new();
671    for line in read_or_empty(&path)?.split(|&b| b == b'\n') {
672        let line = line.trim_ascii();
673        if !line.is_empty()
674            && let Ok(oid) = ObjectId::from_hex(line)
675        {
676            existing.insert(oid);
677        }
678    }
679
680    // Seed the final set with the new boundaries — they are kept
681    // unconditionally regardless of ODB state. The remaining pre-existing
682    // entries are stale candidates: they're kept only if their parents
683    // are still missing from the ODB.
684    let mut final_set: HashSet<ObjectId> = boundaries.iter().copied().collect();
685    existing.retain(|oid| !final_set.contains(oid));
686    let stale = existing;
687
688    if !stale.is_empty() {
689        let repo = gix::open(repo_dir).map_err(|e| GitError::Open(Box::new(e)))?;
690        // Hoisting the ODB handle out of the loop matches the
691        // skip-when-empty guard above: every entry's parent lookup
692        // goes through the same Arc-cloned handle.
693        let odb = repo.objects.clone().into_inner();
694        for oid in stale {
695            if entry_remains_a_boundary(&repo, &odb, oid) {
696                final_set.insert(oid);
697            }
698        }
699    }
700
701    if final_set.is_empty() {
702        // A fully-deepened repository must not retain `.git/shallow`;
703        // the file's mere presence triggers shallow semantics in git.
704        if let Err(e) = std::fs::remove_file(&path)
705            && e.kind() != io::ErrorKind::NotFound
706        {
707            return Err(GitError::Io(e));
708        }
709        return Ok(());
710    }
711
712    // Stable on-disk order. ObjectId: Ord sorts by raw SHA bytes, which
713    // is the same order as the hex strings the file contains.
714    let mut sorted: Vec<ObjectId> = final_set.into_iter().collect();
715    sorted.sort_unstable();
716
717    let mut buf = Vec::with_capacity(sorted.len() * SHA1_HEX_LINE_LEN);
718    for oid in &sorted {
719        writeln!(buf, "{}", oid.to_hex()).map_err(GitError::Io)?;
720    }
721    write_atomic(&path, &buf)
722}
723
724/// Resolve the on-disk git directory for `repo_dir`.
725///
726/// Three layouts are handled in priority order:
727/// 1. `.git/` is a directory → normal clone.
728/// 2. `.git` is a file → linked worktree or `--separate-git-dir`; the
729///    file contains `gitdir: <path>` pointing to the real git dir.
730/// 3. No `.git` entry → bare repository; `repo_dir` is the git dir.
731fn git_dir_for(repo_dir: &Path) -> PathBuf {
732    let candidate = repo_dir.join(".git");
733    if candidate.is_dir() {
734        return candidate;
735    }
736    // Linked-worktree / --separate-git-dir: `.git` is a text file whose
737    // sole content is `gitdir: <path>`. Follow the pointer so that
738    // write_shallow_file lands in the real git directory.
739    if candidate.is_file()
740        && let Ok(content) = std::fs::read_to_string(&candidate)
741        && let Some(rest) = content.trim().strip_prefix("gitdir:")
742    {
743        let pointed = Path::new(rest.trim());
744        let resolved = if pointed.is_absolute() {
745            pointed.to_path_buf()
746        } else {
747            repo_dir.join(pointed)
748        };
749        if resolved.is_dir() {
750            return resolved;
751        }
752    }
753    // Bare repository: the working tree root is the git directory.
754    repo_dir.to_path_buf()
755}
756
757/// Decide whether `oid` is still a shallow boundary in `repo`.
758///
759/// Returns `true` iff `oid` resolves to a commit whose parent set is
760/// non-empty and at least one parent is missing from the ODB. A missing
761/// object, a non-commit, or a parentless commit is treated as stale and
762/// pruned (`false`). Transient lookup errors fall through to `false`
763/// with a `debug!` so a single unreadable boundary cannot block the
764/// rewrite — the worst-case effect is a stale entry being dropped, which
765/// never causes incorrect repository state.
766fn entry_remains_a_boundary(
767    repo: &gix::Repository,
768    odb: &impl gix_pack::Find,
769    oid: ObjectId,
770) -> bool {
771    let object = match repo.find_object(oid) {
772        Ok(o) => o,
773        Err(e) => {
774            debug!(%oid, error = %e, "shallow entry not found in ODB; pruning");
775            return false;
776        }
777    };
778    let commit = match object.peel_to_kind(gix::object::Kind::Commit) {
779        Ok(c) => c.into_commit(),
780        Err(e) => {
781            debug!(%oid, error = %e, "shallow entry does not peel to a commit; pruning");
782            return false;
783        }
784    };
785    // Single-pass: a commit with no parents is a vacuous (root) boundary
786    // and gets pruned; otherwise short-circuit on the first parent that
787    // is still missing from the ODB.
788    let mut parents = commit.parent_ids().map(gix::Id::detach).peekable();
789    if parents.peek().is_none() {
790        return false;
791    }
792    parents.any(|p| !odb.contains(&p))
793}
794
795/// Write a zip archive of the tree at `spec` to `<folder>/repo.zip` and
796/// return the path.
797///
798/// `spec` is any rev-spec gix can resolve — fully-qualified ref, short
799/// branch, tag, or SHA. Uses `gix-archive`'s native zip writer via
800/// [`Repository::worktree_archive`]; no subprocess.
801///
802/// # Errors
803///
804/// Returns [`GitError`] if `spec` cannot be resolved, the object cannot
805/// be peeled to a tree, or writing the zip file fails.
806pub fn archive(repo: &Repository, folder: &Path, spec: &str) -> Result<PathBuf, GitError> {
807    let tree = repo
808        .rev_parse_single(BStr::new(spec))?
809        .object()?
810        .peel_to_kind(gix::object::Kind::Tree)?;
811    let (stream, _index) = repo.worktree_stream(tree.id)?;
812
813    let path = folder.join("repo.zip");
814    let file = std::fs::File::create(&path)?;
815    let buf = std::io::BufWriter::new(file);
816
817    let interrupt = AtomicBool::new(false);
818    let options = gix_archive::Options {
819        format: gix_archive::Format::Zip {
820            compression_level: None,
821        },
822        ..gix_archive::Options::default()
823    };
824    repo.worktree_archive(stream, buf, Discard, &interrupt, options)?;
825    Ok(path)
826}
827
828/// Format `HEAD`'s commit as `"<short-sha> <subject>"`, matching upstream
829/// `git log -1 --pretty=%h %s`. Used as `CodePipeline` metadata in the
830/// `s3+zip` push variant.
831///
832/// # Errors
833///
834/// Returns [`GitError::NoCommits`] if the repository has no commits.
835/// Returns other [`GitError`] variants if the commit object cannot be
836/// decoded or a short id cannot be computed.
837pub fn last_commit_message(repo: &Repository) -> Result<String, GitError> {
838    use gix::head::peel;
839
840    let commit = match repo.head_commit() {
841        Ok(c) => c,
842        Err(gix::reference::head_commit::Error::PeelToCommit(
843            peel::to_commit::Error::PeelToObject(peel::to_object::Error::Unborn { .. }),
844        )) => return Err(GitError::NoCommits),
845        Err(e) => return Err(e.into()),
846    };
847    let short = commit.short_id()?;
848    let message = commit.message()?;
849    Ok(format!("{} {}", short, message.summary().to_str_lossy()))
850}
851
852/// Read a remote's URL out of the repository's configuration.
853///
854/// Tries the fetch URL first and falls back to the push URL, matching
855/// `git remote get-url` semantics.
856///
857/// # Errors
858///
859/// Returns [`GitError::RemoteNotFound`] if the remote does not exist,
860/// [`GitError::RemoteHasNoUrl`] if it has neither a fetch nor a push URL,
861/// [`GitError::NonUtf8RemoteUrl`] if the URL bytes are not valid UTF-8, or
862/// [`GitError::FindRemote`] for other lookup failures.
863pub fn remote_url(repo: &Repository, name: &str) -> Result<String, GitError> {
864    let owned_name = || name.to_owned();
865    let remote = repo.find_remote(BStr::new(name)).map_err(|e| match e {
866        gix::remote::find::existing::Error::NotFound { .. } => {
867            GitError::RemoteNotFound(owned_name())
868        }
869        other => GitError::FindRemote(Box::new(other)),
870    })?;
871    let url = remote
872        .url(Direction::Fetch)
873        .or_else(|| remote.url(Direction::Push))
874        .ok_or_else(|| GitError::RemoteHasNoUrl(owned_name()))?;
875    String::from_utf8(url.to_bstring().into()).map_err(|source| GitError::NonUtf8RemoteUrl {
876        remote: owned_name(),
877        source,
878    })
879}
880
881/// Parsed dotted config key: `<section>[.<subsection>].<name>`.
882///
883/// Matches `git config`'s native splitting: section is the first
884/// dot-segment, name is the last, and any segments in between are joined
885/// with `.` to form the subsection (so `lfs.customtransfer.git-lfs-object-store.path`
886/// yields section=`lfs`, subsection=`customtransfer.git-lfs-object-store`,
887/// name=`path`).
888struct DottedKey<'a> {
889    section: &'a str,
890    subsection: Option<&'a str>,
891    name: &'a str,
892}
893
894fn parse_dotted_key(key: &str) -> Result<DottedKey<'_>, GitError> {
895    let first_dot = key
896        .find('.')
897        .ok_or_else(|| GitError::ConfigKeyParse(key.to_owned()))?;
898    let last_dot = key
899        .rfind('.')
900        .expect("first_dot found, so rfind cannot be None");
901    let section = &key[..first_dot];
902    let name = &key[last_dot + 1..];
903    if section.is_empty() || name.is_empty() {
904        return Err(GitError::ConfigKeyParse(key.to_owned()));
905    }
906    // Native git accepts an empty subsection (`a..b` → `[a ""]`) and
907    // dot-prefixed subsections (`a..b.c` → `[a ".b"]`); preserve that
908    // permissiveness here. We only reject when section or name is empty.
909    let subsection = (first_dot != last_dot).then(|| &key[first_dot + 1..last_dot]);
910    Ok(DottedKey {
911        section,
912        subsection,
913        name,
914    })
915}
916
917/// Resolve the path to the local `.git/config` for the repository
918/// containing `cwd`. Honours `GIT_DIR` and worktree layouts: for linked
919/// worktrees we write to the **common** dir's config (where
920/// `git config --add` writes by default), not the per-worktree
921/// `config.worktree`.
922fn config_path_for_cwd(cwd: &Path) -> Result<PathBuf, GitError> {
923    let repo = gix::discover(cwd)?;
924    Ok(repo.common_dir().join("config"))
925}
926
927fn read_or_empty(path: &Path) -> Result<Vec<u8>, GitError> {
928    match std::fs::read(path) {
929        Ok(bytes) => Ok(bytes),
930        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
931        Err(e) => Err(GitError::Io(e)),
932    }
933}
934
935/// Atomically rewrite `path` with `bytes` via a `gix-lock` file. The lock
936/// path is `<path>.lock`; on commit it is `rename(2)`'d over `path`,
937/// matching native `git config`'s behaviour.
938fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), GitError> {
939    use std::io::Write;
940    let mut lock = gix_lock::File::acquire_to_update_resource(
941        path,
942        gix_lock::acquire::Fail::Immediately,
943        None,
944    )?;
945    lock.write_all(bytes).map_err(GitError::Io)?;
946    lock.commit().map_err(|e| GitError::Io(e.error))?;
947    Ok(())
948}
949
950/// Add a multi-value entry to the repository's local config (`<section>[.<subsection>].<name> = value`).
951///
952/// In-process equivalent of `git config --add <key> <value>`. Used by the
953/// LFS agent's `install` / `enable-debug` subcommands. `--add` semantics
954/// rather than `set` so that re-running `install` does not silently
955/// clobber an existing entry the user added by hand.
956///
957/// The write goes through `gix-lock` (atomic rename via
958/// `<path>.lock`), preserving parity with `git config`'s on-disk
959/// concurrency contract.
960///
961/// # Errors
962///
963/// Returns [`GitError::ConfigKeyParse`] for a malformed dotted key,
964/// [`GitError::ConfigInvalidValueName`] if the value name is rejected by
965/// `gix-config`, [`GitError::ConfigInvalidSectionName`] if the section name
966/// is rejected, [`GitError::Discover`] if the repository cannot be located,
967/// [`GitError::ConfigParse`] if the existing config cannot be parsed,
968/// [`GitError::ConfigLock`] if the lock cannot be acquired, or
969/// [`GitError::Io`] for other file I/O failures.
970pub fn config_add(cwd: &Path, key: &str, value: &str) -> Result<(), GitError> {
971    config_add_many(cwd, &[(key, value)])
972}
973
974/// Batched variant of [`config_add`]: applies every `(key, value)` entry
975/// to the local config in a single read / parse / lock / write cycle.
976///
977/// Used by `lfs::install::install`, which previously paid two full
978/// `gix::discover` + `fs::read` + parse + lock + write cycles to set
979/// `lfs.customtransfer.<agent>.path` and `lfs.standalonetransferagent`
980/// back to back. All entries are validated up front, so a malformed
981/// later entry does not partially-write the file.
982///
983/// # Errors
984///
985/// Returns [`GitError::ConfigKeyParse`] for a malformed dotted key,
986/// [`GitError::ConfigInvalidValueName`] if a value name is rejected by
987/// `gix-config`, [`GitError::ConfigInvalidSectionName`] if a section name is
988/// rejected, [`GitError::Discover`] if the repository cannot be located,
989/// [`GitError::ConfigParse`] if the existing config cannot be parsed,
990/// [`GitError::ConfigLock`] if the lock cannot be acquired, or
991/// [`GitError::Io`] for other file I/O failures.
992pub fn config_add_many(cwd: &Path, entries: &[(&str, &str)]) -> Result<(), GitError> {
993    apply_config_entries(cwd, entries, |file, parsed| {
994        for (parts, value_name, value) in parsed {
995            let subsection = parts.subsection.map(BStr::new);
996            let mut section = file
997                .section_mut_or_create_new(parts.section, subsection)
998                .map_err(|source| GitError::ConfigInvalidSectionName {
999                    name: parts.section.to_owned(),
1000                    source,
1001                })?;
1002            section.push(value_name.clone(), Some(BStr::new(value)));
1003        }
1004        Ok(true)
1005    })
1006}
1007
1008/// Shared scaffolding for [`config_set_many`] and [`config_add_many`]:
1009/// parse every `(key, value)` entry up front, load + parse the
1010/// existing local config, hand both to `mutate`, then write the
1011/// serialised result back atomically if `mutate` reports a change
1012/// (#221).
1013///
1014/// `mutate` is called exactly once and returns `Ok(true)` when the
1015/// caller modified `file` in a way that requires re-serialisation
1016/// (`config_add_many` is unconditional; `config_set_many` is
1017/// idempotent and skips the write when no entry changed).
1018fn apply_config_entries<F>(cwd: &Path, entries: &[(&str, &str)], mutate: F) -> Result<(), GitError>
1019where
1020    F: for<'a> FnOnce(
1021        &mut gix::config::File<'a>,
1022        &[(DottedKey<'a>, ValueName<'a>, &'a str)],
1023    ) -> Result<bool, GitError>,
1024{
1025    if entries.is_empty() {
1026        return Ok(());
1027    }
1028    let parsed: Vec<(DottedKey<'_>, ValueName<'_>, &str)> = entries
1029        .iter()
1030        .map(|(key, value)| {
1031            let parts = parse_dotted_key(key)?;
1032            let value_name = ValueName::try_from(parts.name).map_err(|source| {
1033                GitError::ConfigInvalidValueName {
1034                    name: parts.name.to_owned(),
1035                    source,
1036                }
1037            })?;
1038            Ok::<_, GitError>((parts, value_name, *value))
1039        })
1040        .collect::<Result<_, _>>()?;
1041
1042    let config_path = config_path_for_cwd(cwd)?;
1043    let bytes = read_or_empty(&config_path)?;
1044    let mut file = gix::config::File::from_bytes_no_includes(
1045        &bytes,
1046        GixConfigMetadata::api(),
1047        gix_config_init::Options::default(),
1048    )?;
1049
1050    if !mutate(&mut file, &parsed)? {
1051        return Ok(());
1052    }
1053
1054    let extra: usize = entries.iter().map(|(k, v)| k.len() + v.len() + 16).sum();
1055    let mut serialized = Vec::with_capacity(bytes.len() + extra);
1056    file.write_to(&mut serialized).map_err(GitError::Io)?;
1057    write_atomic(&config_path, &serialized)
1058}
1059
1060/// Remove the latest value for the given key from the repository's local config.
1061///
1062/// In-process equivalent of `git config --unset <key>`. Used by the LFS
1063/// agent's `disable-debug` subcommand. Returns
1064/// [`GitError::ConfigKeyNotSet`] when the section or value is absent;
1065/// callers that want idempotent behaviour should match on that.
1066///
1067/// Divergence from `git config --unset`: native git refuses to unset a
1068/// multi-valued key (it requires `--unset-all`). `gix-config` removes
1069/// only the latest value. The keys this helper is used with
1070/// (`lfs.customtransfer.<agent>.args`) are single-valued in practice,
1071/// so the divergence is not observable here.
1072///
1073/// # Errors
1074///
1075/// Returns [`GitError::ConfigKeyParse`] if `key` is malformed,
1076/// [`GitError::Discover`] if the repository cannot be located,
1077/// [`GitError::ConfigParse`] if the existing config cannot be parsed,
1078/// [`GitError::ConfigKeyNotSet`] if the section or value is absent,
1079/// [`GitError::ConfigLock`] if the lock cannot be acquired, or
1080/// [`GitError::Io`] for other file I/O failures.
1081pub fn config_unset(cwd: &Path, key: &str) -> Result<(), GitError> {
1082    let parts = parse_dotted_key(key)?;
1083    let config_path = config_path_for_cwd(cwd)?;
1084    let bytes = read_or_empty(&config_path)?;
1085    let mut file = gix::config::File::from_bytes_no_includes(
1086        &bytes,
1087        GixConfigMetadata::api(),
1088        gix_config_init::Options::default(),
1089    )?;
1090    let subsection = parts.subsection.map(BStr::new);
1091    let Ok(mut section) = file.section_mut(parts.section, subsection) else {
1092        return Err(GitError::ConfigKeyNotSet(key.to_owned()));
1093    };
1094    if section.remove(parts.name).is_none() {
1095        return Err(GitError::ConfigKeyNotSet(key.to_owned()));
1096    }
1097
1098    let mut serialized = Vec::with_capacity(bytes.len());
1099    file.write_to(&mut serialized).map_err(GitError::Io)?;
1100    write_atomic(&config_path, &serialized)
1101}
1102
1103/// Idempotent variant of [`config_unset`]: succeeds even when the key is
1104/// already absent.
1105///
1106/// `disable-debug` style operations want "ensure this key is gone" semantics
1107/// — re-running on a repo that never had the key set should not fail. This
1108/// helper swallows [`GitError::ConfigKeyNotSet`] and propagates every other
1109/// error.
1110///
1111/// # Errors
1112///
1113/// Returns the same errors as [`config_unset`] except
1114/// [`GitError::ConfigKeyNotSet`], which is treated as success.
1115pub fn config_unset_if_present(cwd: &Path, key: &str) -> Result<(), GitError> {
1116    match config_unset(cwd, key) {
1117        Ok(()) | Err(GitError::ConfigKeyNotSet(_)) => Ok(()),
1118        Err(e) => Err(e),
1119    }
1120}
1121
1122/// Set a single-value entry in the repository's local config, replacing any
1123/// existing values for the key.
1124///
1125/// In-process equivalent of `git config <key> <value>` (without `--add`).
1126/// Used by the LFS agent's `install` / `enable-debug` subcommands to provide
1127/// idempotent re-installs: re-running with the same `(key, value)` is a
1128/// no-op, and re-running after the value changes replaces the old entry
1129/// instead of accumulating duplicates.
1130///
1131/// # Errors
1132///
1133/// Returns the same errors as [`config_set_many`].
1134pub fn config_set(cwd: &Path, key: &str, value: &str) -> Result<(), GitError> {
1135    config_set_many(cwd, &[(key, value)])
1136}
1137
1138/// Batched variant of [`config_set`]: applies every `(key, value)` entry to
1139/// the local config in a single read / parse / lock / write cycle.
1140///
1141/// For each entry the helper enforces single-value semantics:
1142///
1143/// - If the key has exactly one existing value and it already equals
1144///   `value`, no change is made for that entry.
1145/// - Otherwise, every existing value for the key is removed and a single
1146///   `value` is pushed. This cleans up legacy multi-valued state left
1147///   behind by older versions that used `--add` semantics here (see #198).
1148///
1149/// If none of the entries require a change the file is not rewritten, so
1150/// re-running install on an already-installed repo touches no bytes.
1151///
1152/// # Errors
1153///
1154/// Returns [`GitError::ConfigKeyParse`] for a malformed dotted key,
1155/// [`GitError::ConfigInvalidValueName`] if a value name is rejected by
1156/// `gix-config`, [`GitError::ConfigInvalidSectionName`] if a section name is
1157/// rejected, [`GitError::Discover`] if the repository cannot be located,
1158/// [`GitError::ConfigParse`] if the existing config cannot be parsed,
1159/// [`GitError::ConfigLock`] if the lock cannot be acquired, or
1160/// [`GitError::Io`] for other file I/O failures.
1161pub fn config_set_many(cwd: &Path, entries: &[(&str, &str)]) -> Result<(), GitError> {
1162    apply_config_entries(cwd, entries, |file, parsed| {
1163        let mut changed = false;
1164        for (parts, value_name, value) in parsed {
1165            let subsection = parts.subsection.map(BStr::new);
1166            let mut section = file
1167                .section_mut_or_create_new(parts.section, subsection)
1168                .map_err(|source| GitError::ConfigInvalidSectionName {
1169                    name: parts.section.to_owned(),
1170                    source,
1171                })?;
1172            let existing = section.values(parts.name);
1173            // Idempotent path: a single existing entry equal to `value` is the
1174            // desired final state — no write needed.
1175            if existing.len() == 1 && existing[0].as_ref() == value.as_bytes() {
1176                continue;
1177            }
1178            // Strip legacy duplicates (or a stale single value) and re-push a
1179            // single canonical entry. `SectionMut::remove` removes the latest
1180            // matching value per call, so loop until exhausted.
1181            while section.remove(parts.name).is_some() {}
1182            section.push(value_name.clone(), Some(BStr::new(value)));
1183            changed = true;
1184        }
1185        Ok(changed)
1186    })
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191    use super::*;
1192
1193    use gix::actor::SignatureRef;
1194    use gix::bstr::BStr;
1195    use gix_pack::Find as _;
1196    use std::sync::OnceLock;
1197    use tempfile::TempDir;
1198
1199    fn signature() -> SignatureRef<'static> {
1200        SignatureRef {
1201            name: BStr::new("Test"),
1202            email: BStr::new("test@example.com"),
1203            time: "0 +0000",
1204        }
1205    }
1206
1207    fn empty_repo() -> (Repository, TempDir) {
1208        let dir = TempDir::new().expect("tempdir");
1209        let repo = gix::init(dir.path()).expect("gix::init");
1210        (repo, dir)
1211    }
1212
1213    /// Persist a one-blob tree so `archive()` has something to emit and
1214    /// bundle round-trips carry real content. `repo.empty_tree()` builds
1215    /// a `Tree` value without writing it, which would leave commits
1216    /// referencing a dangling tree id.
1217    fn make_marker_tree(repo: &Repository) -> ObjectId {
1218        use gix::objs::tree::{Entry, EntryKind};
1219        let blob_id = repo.write_blob(b"hello\n").expect("write blob").detach();
1220        let tree = gix::objs::Tree {
1221            entries: vec![Entry {
1222                mode: EntryKind::Blob.into(),
1223                filename: "marker".into(),
1224                oid: blob_id,
1225            }],
1226        };
1227        repo.write_object(&tree).expect("write tree").detach()
1228    }
1229
1230    fn add_commit(
1231        repo: &Repository,
1232        ref_name: &str,
1233        parents: &[ObjectId],
1234        message: &str,
1235    ) -> ObjectId {
1236        let tree_id = make_marker_tree(repo);
1237        let id = repo
1238            .commit_as(
1239                signature(),
1240                signature(),
1241                ref_name,
1242                message,
1243                tree_id,
1244                parents.iter().copied(),
1245            )
1246            .expect("commit_as");
1247        id.detach()
1248    }
1249
1250    /// Write a commit object whose parent list contains OIDs that may
1251    /// or may not be present in the ODB — the gix object writer does
1252    /// not check parent reachability. Used to construct synthetic
1253    /// "orphan" or "octopus with a missing parent" inputs for the
1254    /// shallow-pruning tests.
1255    fn commit_with_synthetic_parents(
1256        repo: &Repository,
1257        parents: &[ObjectId],
1258        message: &str,
1259    ) -> ObjectId {
1260        let tree_id = make_marker_tree(repo);
1261        let sig = gix::actor::Signature {
1262            name: "Test".into(),
1263            email: "test@example.com".into(),
1264            time: gix::date::Time::default(),
1265        };
1266        let commit = gix::objs::Commit {
1267            tree: tree_id,
1268            parents: parents.iter().copied().collect(),
1269            author: sig.clone(),
1270            committer: sig,
1271            encoding: None,
1272            message: message.into(),
1273            extra_headers: Vec::new(),
1274        };
1275        repo.write_object(&commit).expect("write commit").detach()
1276    }
1277
1278    fn git_available() -> bool {
1279        static AVAIL: OnceLock<bool> = OnceLock::new();
1280        *AVAIL.get_or_init(|| {
1281            std::process::Command::new("git")
1282                .arg("--version")
1283                .output()
1284                .is_ok()
1285        })
1286    }
1287
1288    // --- Sha ----------------------------------------------------------
1289
1290    #[test]
1291    fn sha_from_hex_accepts_valid_lowercase_sha1() {
1292        let s = Sha::from_hex("0123456789abcdef0123456789abcdef01234567").expect("valid");
1293        assert_eq!(s.to_string(), "0123456789abcdef0123456789abcdef01234567");
1294    }
1295
1296    #[test]
1297    fn sha_from_hex_accepts_uppercase_and_normalizes_to_lowercase() {
1298        let s = Sha::from_hex("0123456789ABCDEF0123456789ABCDEF01234567").expect("valid");
1299        assert_eq!(s.to_string(), "0123456789abcdef0123456789abcdef01234567");
1300    }
1301
1302    #[test]
1303    fn sha_from_hex_rejects_wrong_length() {
1304        assert!(Sha::from_hex("abc").is_err());
1305        assert!(Sha::from_hex(&"a".repeat(39)).is_err());
1306        assert!(Sha::from_hex(&"a".repeat(41)).is_err());
1307    }
1308
1309    #[test]
1310    fn sha_from_hex_rejects_non_hex() {
1311        assert!(Sha::from_hex(&"g".repeat(40)).is_err());
1312        assert!(Sha::from_hex("0123456789abcdef0123456789abcdef0123456 ").is_err());
1313    }
1314
1315    #[test]
1316    fn sha_from_hex_rejects_empty() {
1317        assert!(matches!(Sha::from_hex(""), Err(ShaError::Empty)));
1318    }
1319
1320    // --- RefName / is_valid_ref_name ----------------------------------
1321
1322    const INVALID_REF_NAMES: &[&str] = &[
1323        "",
1324        ".hidden",
1325        "refs/heads/.hidden",
1326        "refs/heads/foo..bar",
1327        "refs/heads/foo bar",
1328        "refs/heads/",
1329        "refs/heads/main.lock",
1330        "refs/heads/main@{x}",
1331        "refs/heads//main",
1332        "refs/heads/main\x01",
1333        "refs/heads/?bad",
1334        "refs/heads/[bad]",
1335        "refs/heads/^bad",
1336        "refs/heads/~bad",
1337        "refs/heads/*bad",
1338        "refs/heads/:bad",
1339    ];
1340
1341    #[test]
1342    fn ref_name_new_accepts_canonical_refs() {
1343        assert!(RefName::new("refs/heads/main").is_ok());
1344        assert!(RefName::new("refs/heads/feature/x").is_ok());
1345        assert!(RefName::new("refs/tags/v1").is_ok());
1346    }
1347
1348    #[test]
1349    fn ref_name_new_rejects_each_invalid_category() {
1350        for name in INVALID_REF_NAMES {
1351            assert!(
1352                RefName::new(*name).is_err(),
1353                "expected RefName::new({name:?}) to fail",
1354            );
1355        }
1356    }
1357
1358    #[test]
1359    fn ref_name_is_valid_matches_new() {
1360        // `RefName::is_valid` is the borrow-only predicate equivalent
1361        // of `RefName::new(...).is_ok()`. Pin parity on both sides so a
1362        // future glue change to `gix-validate` can't drift the two
1363        // surfaces apart.
1364        for name in ["refs/heads/main", "refs/heads/feature/x", "refs/tags/v1"] {
1365            assert!(RefName::is_valid(name), "expected is_valid({name:?})");
1366        }
1367        for name in INVALID_REF_NAMES {
1368            assert!(!RefName::is_valid(name), "expected !is_valid({name:?})");
1369        }
1370    }
1371
1372    #[test]
1373    fn is_valid_ref_name_partial_accepts_single_component_head() {
1374        // The partial validator accepts `HEAD`; the strict `RefName::new`
1375        // would reject it because it isn't fully qualified.
1376        assert!(is_valid_ref_name("HEAD"));
1377    }
1378
1379    #[test]
1380    fn is_valid_ref_name_partial_rejects_each_invalid_category() {
1381        // Empty and trailing-slash are rejected by `name_partial`.
1382        for name in &[
1383            "",
1384            "refs/heads/.hidden",
1385            "refs/heads/foo..bar",
1386            "refs/heads/main.lock",
1387        ] {
1388            assert!(!is_valid_ref_name(name), "expected !{name:?}");
1389        }
1390    }
1391
1392    // --- is_ancestor / archive / last_commit_message / remote_url
1393
1394    #[test]
1395    fn is_ancestor_self_is_true() {
1396        let (repo, _dir) = empty_repo();
1397        let a = add_commit(&repo, "refs/heads/main", &[], "first");
1398        let sa = Sha::from_object_id(a);
1399        assert!(is_ancestor(&repo, sa, sa).expect("is_ancestor"));
1400    }
1401
1402    #[test]
1403    fn is_ancestor_parent_of_child_is_true() {
1404        let (repo, _dir) = empty_repo();
1405        let a = add_commit(&repo, "refs/heads/main", &[], "a");
1406        let b = add_commit(&repo, "refs/heads/main", &[a], "b");
1407        assert!(
1408            is_ancestor(&repo, Sha::from_object_id(a), Sha::from_object_id(b))
1409                .expect("is_ancestor")
1410        );
1411    }
1412
1413    #[test]
1414    fn is_ancestor_reverse_is_false() {
1415        let (repo, _dir) = empty_repo();
1416        let a = add_commit(&repo, "refs/heads/main", &[], "a");
1417        let b = add_commit(&repo, "refs/heads/main", &[a], "b");
1418        assert!(
1419            !is_ancestor(&repo, Sha::from_object_id(b), Sha::from_object_id(a))
1420                .expect("is_ancestor")
1421        );
1422    }
1423
1424    #[test]
1425    fn is_ancestor_unrelated_is_false() {
1426        let (repo, _dir) = empty_repo();
1427        let a = add_commit(&repo, "refs/heads/main", &[], "a");
1428        let b = add_commit(&repo, "refs/heads/side", &[], "b");
1429        assert!(
1430            !is_ancestor(&repo, Sha::from_object_id(a), Sha::from_object_id(b))
1431                .expect("is_ancestor")
1432        );
1433    }
1434
1435    // --- peel_tag_chain -----------------------------------------------
1436
1437    fn write_annotated_tag(
1438        repo: &Repository,
1439        target: ObjectId,
1440        target_kind: gix::object::Kind,
1441        name: &str,
1442    ) -> ObjectId {
1443        let tag = gix::objs::Tag {
1444            target,
1445            target_kind,
1446            name: name.into(),
1447            tagger: Some(signature().to_owned().expect("static signature is valid")),
1448            message: "test".into(),
1449            pgp_signature: None,
1450        };
1451        repo.write_object(&tag).expect("write tag").detach()
1452    }
1453
1454    #[test]
1455    fn peel_lightweight_tag_returns_commit_with_empty_chain() {
1456        // A lightweight tag is a ref pointing directly at a commit — there
1457        // is no tag object to walk through. We pass the commit OID
1458        // directly (the same OID `git::branch::resolve` would return for
1459        // a lightweight tag ref).
1460        let (repo, _dir) = empty_repo();
1461        let commit = add_commit(&repo, "refs/heads/main", &[], "c");
1462        let peeled = peel_tag_chain(&repo, Sha::from_object_id(commit)).expect("peel");
1463        match peeled {
1464            PeeledTip::Commit {
1465                commit: peeled_commit,
1466                tag_chain,
1467            } => {
1468                assert_eq!(peeled_commit.as_object_id(), &commit);
1469                assert!(tag_chain.is_empty());
1470            }
1471            other => panic!("expected Commit variant, got {:?}", variant_name(&other)),
1472        }
1473    }
1474
1475    #[test]
1476    fn peel_annotated_tag_returns_commit_with_one_element_chain() {
1477        let (repo, _dir) = empty_repo();
1478        let commit = add_commit(&repo, "refs/heads/main", &[], "c");
1479        let tag = write_annotated_tag(&repo, commit, gix::object::Kind::Commit, "v1");
1480        let peeled = peel_tag_chain(&repo, Sha::from_object_id(tag)).expect("peel");
1481        match peeled {
1482            PeeledTip::Commit {
1483                commit: peeled_commit,
1484                tag_chain,
1485            } => {
1486                assert_eq!(peeled_commit.as_object_id(), &commit);
1487                assert_eq!(tag_chain, vec![tag]);
1488            }
1489            other => panic!("expected Commit variant, got {:?}", variant_name(&other)),
1490        }
1491    }
1492
1493    #[test]
1494    fn peel_tag_of_tag_returns_commit_with_outer_then_inner_chain() {
1495        let (repo, _dir) = empty_repo();
1496        let commit = add_commit(&repo, "refs/heads/main", &[], "c");
1497        let inner = write_annotated_tag(&repo, commit, gix::object::Kind::Commit, "inner");
1498        let outer = write_annotated_tag(&repo, inner, gix::object::Kind::Tag, "outer");
1499        let peeled = peel_tag_chain(&repo, Sha::from_object_id(outer)).expect("peel");
1500        match peeled {
1501            PeeledTip::Commit {
1502                commit: peeled_commit,
1503                tag_chain,
1504            } => {
1505                assert_eq!(peeled_commit.as_object_id(), &commit);
1506                // Walk order: outer encountered first, then inner.
1507                assert_eq!(tag_chain, vec![outer, inner]);
1508            }
1509            other => panic!("expected Commit variant, got {:?}", variant_name(&other)),
1510        }
1511    }
1512
1513    /// Build a freestanding tree object suitable for `peel_tag_chain` tests.
1514    fn write_tree_with_one_blob(repo: &gix::Repository) -> (ObjectId, ObjectId) {
1515        use gix::objs::tree::{Entry, EntryKind};
1516        let blob = repo.write_blob(b"x").expect("write blob").detach();
1517        let tree = repo
1518            .write_object(&gix::objs::Tree {
1519                entries: vec![Entry {
1520                    mode: EntryKind::Blob.into(),
1521                    filename: "x".into(),
1522                    oid: blob,
1523                }],
1524            })
1525            .expect("write tree")
1526            .detach();
1527        (tree, blob)
1528    }
1529
1530    #[test]
1531    fn peel_tag_pointing_to_tree_returns_tree_variant() {
1532        let (repo, _dir) = empty_repo();
1533        let (tree_id, _blob) = write_tree_with_one_blob(&repo);
1534        let tag = write_annotated_tag(&repo, tree_id, gix::object::Kind::Tree, "tree-tag");
1535        let peeled = peel_tag_chain(&repo, Sha::from_object_id(tag)).expect("peel");
1536        match peeled {
1537            PeeledTip::Tree { tree, tag_chain } => {
1538                assert_eq!(tree, tree_id);
1539                assert_eq!(tag_chain, vec![tag]);
1540            }
1541            other => panic!("expected Tree variant, got {:?}", variant_name(&other)),
1542        }
1543    }
1544
1545    #[test]
1546    fn peel_tag_pointing_to_blob_returns_blob_variant() {
1547        let (repo, _dir) = empty_repo();
1548        let blob_id = repo.write_blob(b"data").expect("write blob").detach();
1549        let tag = write_annotated_tag(&repo, blob_id, gix::object::Kind::Blob, "blob-tag");
1550        let peeled = peel_tag_chain(&repo, Sha::from_object_id(tag)).expect("peel");
1551        match peeled {
1552            PeeledTip::Blob { blob, tag_chain } => {
1553                assert_eq!(blob, blob_id);
1554                assert_eq!(tag_chain, vec![tag]);
1555            }
1556            other => panic!("expected Blob variant, got {:?}", variant_name(&other)),
1557        }
1558    }
1559
1560    #[test]
1561    fn peel_tag_of_tag_of_tree_returns_tree_with_outer_then_inner_chain() {
1562        let (repo, _dir) = empty_repo();
1563        let (tree_id, _blob) = write_tree_with_one_blob(&repo);
1564        let inner = write_annotated_tag(&repo, tree_id, gix::object::Kind::Tree, "inner");
1565        let outer = write_annotated_tag(&repo, inner, gix::object::Kind::Tag, "outer");
1566        let peeled = peel_tag_chain(&repo, Sha::from_object_id(outer)).expect("peel");
1567        match peeled {
1568            PeeledTip::Tree { tree, tag_chain } => {
1569                assert_eq!(tree, tree_id);
1570                assert_eq!(tag_chain, vec![outer, inner]);
1571            }
1572            other => panic!("expected Tree variant, got {:?}", variant_name(&other)),
1573        }
1574    }
1575
1576    #[test]
1577    fn peel_depth_three_tag_chain_to_blob_preserves_chain_order() {
1578        // Three nested tags ending at a blob. Catches off-by-one in the
1579        // walk that tag-of-tag (depth 2) tests would miss.
1580        let (repo, _dir) = empty_repo();
1581        let blob_id = repo.write_blob(b"data").expect("write blob").detach();
1582        let inner = write_annotated_tag(&repo, blob_id, gix::object::Kind::Blob, "inner");
1583        let middle = write_annotated_tag(&repo, inner, gix::object::Kind::Tag, "middle");
1584        let outer = write_annotated_tag(&repo, middle, gix::object::Kind::Tag, "outer");
1585        let peeled = peel_tag_chain(&repo, Sha::from_object_id(outer)).expect("peel");
1586        match peeled {
1587            PeeledTip::Blob { blob, tag_chain } => {
1588                assert_eq!(blob, blob_id);
1589                assert_eq!(tag_chain, vec![outer, middle, inner]);
1590            }
1591            other => panic!("expected Blob variant, got {:?}", variant_name(&other)),
1592        }
1593    }
1594
1595    #[test]
1596    fn peel_bare_tree_ref_returns_tree_with_empty_chain() {
1597        // A ref pointing directly at a tree (no tag wrapper) is legal in
1598        // git. Empty tag_chain is the natural fallout of treating chain
1599        // length and leaf kind as orthogonal.
1600        let (repo, _dir) = empty_repo();
1601        let (tree_id, _blob) = write_tree_with_one_blob(&repo);
1602        let peeled = peel_tag_chain(&repo, Sha::from_object_id(tree_id)).expect("peel");
1603        match peeled {
1604            PeeledTip::Tree { tree, tag_chain } => {
1605                assert_eq!(tree, tree_id);
1606                assert!(tag_chain.is_empty());
1607            }
1608            other => panic!("expected Tree variant, got {:?}", variant_name(&other)),
1609        }
1610    }
1611
1612    #[test]
1613    fn peel_bare_blob_ref_returns_blob_with_empty_chain() {
1614        let (repo, _dir) = empty_repo();
1615        let blob_id = repo.write_blob(b"data").expect("write blob").detach();
1616        let peeled = peel_tag_chain(&repo, Sha::from_object_id(blob_id)).expect("peel");
1617        match peeled {
1618            PeeledTip::Blob { blob, tag_chain } => {
1619                assert_eq!(blob, blob_id);
1620                assert!(tag_chain.is_empty());
1621            }
1622            other => panic!("expected Blob variant, got {:?}", variant_name(&other)),
1623        }
1624    }
1625
1626    fn variant_name(p: &PeeledTip) -> &'static str {
1627        match p {
1628            PeeledTip::Commit { .. } => "Commit",
1629            PeeledTip::Tree { .. } => "Tree",
1630            PeeledTip::Blob { .. } => "Blob",
1631        }
1632    }
1633
1634    #[test]
1635    fn archive_writes_repo_zip_with_pk_header() {
1636        let (repo, dir) = empty_repo();
1637        add_commit(&repo, "refs/heads/main", &[], "first");
1638        let out_dir = TempDir::new().expect("tempdir");
1639        let zip_path = archive(&repo, out_dir.path(), "refs/heads/main").expect("archive");
1640        assert_eq!(zip_path, out_dir.path().join("repo.zip"));
1641        let bytes = std::fs::read(&zip_path).expect("read zip");
1642        assert_eq!(&bytes[..4], b"PK\x03\x04", "zip local-file-header missing");
1643        drop(dir);
1644    }
1645
1646    #[test]
1647    fn archive_resolves_tag_through_peel() {
1648        // Annotated tag → commit → tree peel chain. This exercises the
1649        // tag-handling branch in `peel_to_kind` that the branch test
1650        // skips.
1651        let (repo, _dir) = empty_repo();
1652        let commit_oid = add_commit(&repo, "refs/heads/main", &[], "first");
1653        let tag = gix::objs::Tag {
1654            target: commit_oid,
1655            target_kind: gix::object::Kind::Commit,
1656            name: "v1".into(),
1657            tagger: Some(signature().to_owned().expect("static signature is valid")),
1658            message: "release".into(),
1659            pgp_signature: None,
1660        };
1661        let tag_id = repo.write_object(&tag).expect("write tag").detach();
1662        repo.reference(
1663            "refs/tags/v1",
1664            tag_id,
1665            gix::refs::transaction::PreviousValue::MustNotExist,
1666            "create tag",
1667        )
1668        .expect("create tag ref");
1669        let out_dir = TempDir::new().expect("tempdir");
1670        let zip_path = archive(&repo, out_dir.path(), "refs/tags/v1").expect("archive tag");
1671        let bytes = std::fs::read(&zip_path).expect("read zip");
1672        assert_eq!(&bytes[..4], b"PK\x03\x04");
1673    }
1674
1675    #[test]
1676    fn last_commit_message_format_short_sha_then_subject() {
1677        let (repo, _dir) = empty_repo();
1678        add_commit(&repo, "refs/heads/main", &[], "Initial commit");
1679        let msg = last_commit_message(&repo).expect("last_commit_message");
1680        let mut parts = msg.splitn(2, ' ');
1681        let short = parts.next().expect("short");
1682        let subject = parts.next().expect("subject");
1683        assert!(short.len() >= 4, "short id too short: {short:?}");
1684        assert!(short.chars().all(|c| c.is_ascii_hexdigit()));
1685        assert_eq!(subject, "Initial commit");
1686    }
1687
1688    #[test]
1689    fn last_commit_message_unborn_head_returns_no_commits() {
1690        let (repo, _dir) = empty_repo();
1691        assert!(matches!(
1692            last_commit_message(&repo),
1693            Err(GitError::NoCommits)
1694        ));
1695    }
1696
1697    #[test]
1698    fn remote_url_returns_fetch_url() {
1699        let (repo, dir) = empty_repo();
1700        let url = "https://example.com/repo.git";
1701        let config_path = repo.git_dir().join("config");
1702        let existing = std::fs::read_to_string(&config_path).expect("read config");
1703        let amended = format!(
1704            "{existing}\n[remote \"origin\"]\n\turl = {url}\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n"
1705        );
1706        std::fs::write(&config_path, amended).expect("write config");
1707        // Re-open so the new config is visible.
1708        let repo = gix::open(repo.git_dir()).expect("re-open");
1709        let got = remote_url(&repo, "origin").expect("remote_url");
1710        assert_eq!(got, url);
1711        drop(dir);
1712    }
1713
1714    #[test]
1715    fn remote_url_unknown_remote_returns_remote_not_found() {
1716        let (repo, _dir) = empty_repo();
1717        assert!(matches!(
1718            remote_url(&repo, "missing"),
1719            Err(GitError::RemoteNotFound(_))
1720        ));
1721    }
1722
1723    #[test]
1724    fn remote_url_falls_back_to_push_url_when_fetch_url_absent() {
1725        // A remote with only `pushurl` and no `url` should still resolve.
1726        // gix's `find_remote` parses the section name from any of url
1727        // or pushurl, so we set pushurl alone.
1728        let (repo, dir) = empty_repo();
1729        let push_url = "https://example.com/push.git";
1730        let config_path = repo.git_dir().join("config");
1731        let existing = std::fs::read_to_string(&config_path).expect("read config");
1732        let amended = format!("{existing}\n[remote \"only-push\"]\n\tpushurl = {push_url}\n");
1733        std::fs::write(&config_path, amended).expect("write config");
1734        let repo = gix::open(repo.git_dir()).expect("re-open");
1735        let got = remote_url(&repo, "only-push").expect("remote_url");
1736        assert_eq!(got, push_url);
1737        drop(dir);
1738    }
1739
1740    // --- parse_dotted_key ---------------------------------------------
1741
1742    #[test]
1743    fn parse_dotted_key_two_segments_has_no_subsection() {
1744        let p = parse_dotted_key("lfs.standalonetransferagent").expect("parse");
1745        assert_eq!(p.section, "lfs");
1746        assert_eq!(p.subsection, None);
1747        assert_eq!(p.name, "standalonetransferagent");
1748    }
1749
1750    #[test]
1751    fn parse_dotted_key_three_segments_uses_middle_as_subsection() {
1752        let p = parse_dotted_key("remote.origin.url").expect("parse");
1753        assert_eq!(p.section, "remote");
1754        assert_eq!(p.subsection, Some("origin"));
1755        assert_eq!(p.name, "url");
1756    }
1757
1758    #[test]
1759    fn parse_dotted_key_four_segments_joins_subsection_with_dots() {
1760        // The two-level LFS shape: section=lfs,
1761        // subsection=customtransfer.git-lfs-object-store, name=path.
1762        let p = parse_dotted_key("lfs.customtransfer.git-lfs-object-store.path").expect("parse");
1763        assert_eq!(p.section, "lfs");
1764        assert_eq!(p.subsection, Some("customtransfer.git-lfs-object-store"));
1765        assert_eq!(p.name, "path");
1766    }
1767
1768    #[test]
1769    fn parse_dotted_key_rejects_invalid_shapes() {
1770        // Covers: empty key, no-dot, leading-dot (empty section),
1771        // trailing-dot (empty name), and bare dot. Consecutive dots
1772        // are NOT rejected: native git accepts `a..b` (creates
1773        // `[a ""]`), so we mirror that.
1774        for bad in ["", "nodotsegment", ".name", "section.", "."] {
1775            assert!(
1776                matches!(parse_dotted_key(bad), Err(GitError::ConfigKeyParse(_))),
1777                "expected parse failure for {bad:?}",
1778            );
1779        }
1780    }
1781
1782    #[test]
1783    fn parse_dotted_key_accepts_empty_subsection_for_git_parity() {
1784        // `git config a..b foo` creates `[a ""]\n\tb = foo` — we accept
1785        // the same shape rather than rejecting it.
1786        let p = parse_dotted_key("a..b").expect("parse");
1787        assert_eq!(p.section, "a");
1788        assert_eq!(p.subsection, Some(""));
1789        assert_eq!(p.name, "b");
1790    }
1791
1792    // --- config_add / config_unset (in-process via gix-config) --------
1793
1794    /// Read the local config back as bytes. Tests parse against the
1795    /// committed file, not against a serialized buffer, to verify the
1796    /// atomic-rename actually landed.
1797    fn read_local_config(repo: &Repository) -> String {
1798        let path = repo.common_dir().join("config");
1799        std::fs::read_to_string(&path).expect("read config")
1800    }
1801
1802    /// Re-parse `<git_dir>/config` and return all values for `key` as
1803    /// owned strings. Uses the same `gix-config` machinery the helpers
1804    /// write through, so this asserts behavioural round-trip rather
1805    /// than byte equality.
1806    fn config_values(repo: &Repository, key: &str) -> Vec<String> {
1807        let path = repo.common_dir().join("config");
1808        let bytes = std::fs::read(&path).expect("read config");
1809        let file = gix::config::File::from_bytes_no_includes(
1810            &bytes,
1811            GixConfigMetadata::api(),
1812            gix_config_init::Options::default(),
1813        )
1814        .expect("parse");
1815        file.raw_values(key)
1816            .map(|values| {
1817                values
1818                    .into_iter()
1819                    .map(|v| v.into_owned().to_string())
1820                    .collect()
1821            })
1822            .unwrap_or_default()
1823    }
1824
1825    #[test]
1826    fn config_add_creates_section_and_value() {
1827        let (repo, _dir) = empty_repo();
1828        config_add(
1829            repo.workdir().expect("workdir"),
1830            "lfs.standalonetransferagent",
1831            "git-lfs-object-store",
1832        )
1833        .expect("config_add");
1834        let values = config_values(&repo, "lfs.standalonetransferagent");
1835        assert_eq!(values, vec!["git-lfs-object-store".to_owned()]);
1836    }
1837
1838    #[test]
1839    fn config_add_handles_two_level_subsection() {
1840        let (repo, _dir) = empty_repo();
1841        let key = "lfs.customtransfer.git-lfs-object-store.path";
1842        config_add(
1843            repo.workdir().expect("workdir"),
1844            key,
1845            "git-lfs-object-store",
1846        )
1847        .expect("config_add");
1848        let values = config_values(&repo, key);
1849        assert_eq!(values, vec!["git-lfs-object-store".to_owned()]);
1850    }
1851
1852    #[test]
1853    fn config_add_appends_duplicate_values() {
1854        // `--add` semantics: pushing the same key twice keeps both
1855        // values, matching upstream `git config --add`.
1856        let (repo, _dir) = empty_repo();
1857        let cwd = repo.workdir().expect("workdir");
1858        config_add(cwd, "lfs.standalonetransferagent", "first").expect("first");
1859        config_add(cwd, "lfs.standalonetransferagent", "second").expect("second");
1860        let values = config_values(&repo, "lfs.standalonetransferagent");
1861        assert_eq!(values, vec!["first".to_owned(), "second".to_owned()]);
1862    }
1863
1864    #[test]
1865    fn config_add_preserves_existing_comments() {
1866        let (repo, _dir) = empty_repo();
1867        let path = repo.common_dir().join("config");
1868        let existing = std::fs::read_to_string(&path).expect("read config");
1869        let amended = format!("{existing}# user marker\n[user]\n\tname = Tester\n");
1870        std::fs::write(&path, amended).expect("seed config");
1871
1872        config_add(
1873            repo.workdir().expect("workdir"),
1874            "lfs.standalonetransferagent",
1875            "git-lfs-object-store",
1876        )
1877        .expect("config_add");
1878
1879        let after = read_local_config(&repo);
1880        assert!(
1881            after.contains("# user marker"),
1882            "comment dropped: {after:?}"
1883        );
1884        assert!(
1885            after.contains("name = Tester"),
1886            "user.name dropped: {after:?}"
1887        );
1888        let values = config_values(&repo, "lfs.standalonetransferagent");
1889        assert_eq!(values, vec!["git-lfs-object-store".to_owned()]);
1890    }
1891
1892    #[test]
1893    fn config_add_rejects_invalid_key() {
1894        let (repo, _dir) = empty_repo();
1895        assert!(matches!(
1896            config_add(repo.workdir().expect("workdir"), "", "v"),
1897            Err(GitError::ConfigKeyParse(_))
1898        ));
1899        assert!(matches!(
1900            config_add(repo.workdir().expect("workdir"), "nodot", "v"),
1901            Err(GitError::ConfigKeyParse(_))
1902        ));
1903    }
1904
1905    #[test]
1906    fn config_add_rejects_invalid_value_name() {
1907        // Value names must start with an ASCII alphabetic and contain
1908        // only alphanumeric/dash. Leading digits trip the validator.
1909        let (repo, _dir) = empty_repo();
1910        let err = config_add(repo.workdir().expect("workdir"), "lfs.123bad", "v")
1911            .expect_err("expected validation error");
1912        assert!(
1913            matches!(err, GitError::ConfigInvalidValueName { .. }),
1914            "got {err:?}"
1915        );
1916    }
1917
1918    #[test]
1919    fn config_add_many_writes_all_entries_in_one_pass() {
1920        // Both entries land in the file. Order is preserved within a
1921        // section but `lfs.standalonetransferagent` lives directly under
1922        // `[lfs]` while the path key lives under
1923        // `[lfs "customtransfer.git-lfs-object-store"]`, so we just
1924        // assert each value is readable rather than asserting a
1925        // particular file ordering.
1926        let (repo, _dir) = empty_repo();
1927        let entries: &[(&str, &str)] = &[
1928            (
1929                "lfs.customtransfer.git-lfs-object-store.path",
1930                "git-lfs-object-store",
1931            ),
1932            ("lfs.standalonetransferagent", "git-lfs-object-store"),
1933        ];
1934        config_add_many(repo.workdir().expect("workdir"), entries).expect("config_add_many");
1935        for (key, value) in entries {
1936            assert_eq!(config_values(&repo, key), vec![(*value).to_owned()]);
1937        }
1938    }
1939
1940    #[test]
1941    fn config_add_many_validates_all_entries_before_writing() {
1942        // A malformed key in any position must abort *before* we touch
1943        // the file — otherwise an earlier valid entry would be persisted
1944        // alongside the failure, leaving the repo in a half-installed
1945        // state.
1946        let (repo, _dir) = empty_repo();
1947        let cwd = repo.workdir().expect("workdir");
1948        let path_before = read_local_config(&repo);
1949        let err = config_add_many(
1950            cwd,
1951            &[
1952                ("lfs.standalonetransferagent", "git-lfs-object-store"),
1953                ("nodot", "v"),
1954            ],
1955        )
1956        .expect_err("expected parse failure on second entry");
1957        assert!(matches!(err, GitError::ConfigKeyParse(_)), "got {err:?}");
1958        assert_eq!(read_local_config(&repo), path_before);
1959        assert!(
1960            config_values(&repo, "lfs.standalonetransferagent").is_empty(),
1961            "first entry should not have been written",
1962        );
1963    }
1964
1965    #[test]
1966    fn config_add_many_empty_input_is_noop() {
1967        let (repo, _dir) = empty_repo();
1968        let cwd = repo.workdir().expect("workdir");
1969        let before = read_local_config(&repo);
1970        config_add_many(cwd, &[]).expect("noop");
1971        assert_eq!(read_local_config(&repo), before);
1972    }
1973
1974    #[test]
1975    fn config_unset_removes_existing_value() {
1976        let (repo, _dir) = empty_repo();
1977        let cwd = repo.workdir().expect("workdir");
1978        config_add(cwd, "lfs.customtransfer.git-lfs-object-store.args", "debug").expect("seed");
1979        config_unset(cwd, "lfs.customtransfer.git-lfs-object-store.args").expect("unset");
1980        let values = config_values(&repo, "lfs.customtransfer.git-lfs-object-store.args");
1981        assert!(values.is_empty(), "value still present: {values:?}");
1982    }
1983
1984    #[test]
1985    fn config_unset_missing_key_returns_typed_error() {
1986        let (repo, _dir) = empty_repo();
1987        let err = config_unset(repo.workdir().expect("workdir"), "lfs.never.set")
1988            .expect_err("expected error");
1989        assert!(matches!(err, GitError::ConfigKeyNotSet(ref k) if k == "lfs.never.set"));
1990    }
1991
1992    #[test]
1993    fn config_unset_missing_section_returns_typed_error() {
1994        let (repo, _dir) = empty_repo();
1995        // Even when the section itself is absent, we surface
1996        // ConfigKeyNotSet (parity with `git config --unset` exiting
1997        // non-zero in that case).
1998        let err = config_unset(repo.workdir().expect("workdir"), "ghost.value")
1999            .expect_err("expected error");
2000        assert!(matches!(err, GitError::ConfigKeyNotSet(_)), "got {err:?}");
2001    }
2002
2003    #[test]
2004    fn config_unset_missing_key_within_existing_section_returns_typed_error() {
2005        // Distinct from the above: here the section IS present (we just
2006        // wrote a value to it), so `section_mut` succeeds and the error
2007        // must come from `section.remove()` returning None. Without this
2008        // test the second `ConfigKeyNotSet` branch in `config_unset`
2009        // would be unreachable from the suite.
2010        let (repo, _dir) = empty_repo();
2011        let cwd = repo.workdir().expect("workdir");
2012        config_add(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("seed");
2013        let err = config_unset(cwd, "lfs.othervalue").expect_err("expected error");
2014        assert!(
2015            matches!(err, GitError::ConfigKeyNotSet(ref k) if k == "lfs.othervalue"),
2016            "got {err:?}"
2017        );
2018    }
2019
2020    #[test]
2021    fn config_add_then_native_git_can_read_value() {
2022        // Cross-tool parity: a value written by our gix-config helper
2023        // is readable by the native `git config --get` CLI.
2024        if !git_available() {
2025            eprintln!("skipping: git not on PATH");
2026            return;
2027        }
2028        let (repo, _dir) = empty_repo();
2029        let cwd = repo.workdir().expect("workdir");
2030        config_add(
2031            cwd,
2032            "lfs.customtransfer.git-lfs-object-store.path",
2033            "git-lfs-object-store",
2034        )
2035        .expect("config_add");
2036
2037        let output = std::process::Command::new("git")
2038            .args([
2039                "config",
2040                "--get",
2041                "lfs.customtransfer.git-lfs-object-store.path",
2042            ])
2043            .current_dir(cwd)
2044            .output()
2045            .expect("git config --get");
2046        assert!(
2047            output.status.success(),
2048            "stderr: {}",
2049            String::from_utf8_lossy(&output.stderr)
2050        );
2051        let stdout = String::from_utf8(output.stdout).expect("utf8");
2052        assert_eq!(stdout.trim(), "git-lfs-object-store");
2053    }
2054
2055    // --- config_set / config_set_many / config_unset_if_present ------
2056    //
2057    // These exercise the idempotency contract used by `lfs::install` and
2058    // friends (issues #198, #210): re-running set-style writes must not
2059    // accumulate duplicates, and unset-if-present must not error when the
2060    // key is already gone.
2061
2062    #[test]
2063    fn config_set_writes_value_when_key_absent() {
2064        let (repo, _dir) = empty_repo();
2065        let cwd = repo.workdir().expect("workdir");
2066        config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("config_set");
2067        assert_eq!(
2068            config_values(&repo, "lfs.standalonetransferagent"),
2069            vec!["git-lfs-object-store".to_owned()],
2070        );
2071    }
2072
2073    #[test]
2074    fn config_set_is_idempotent_on_matching_value() {
2075        // Two back-to-back sets with the same value must leave exactly one
2076        // entry — this is the primary regression test for issue #198.
2077        let (repo, _dir) = empty_repo();
2078        let cwd = repo.workdir().expect("workdir");
2079        config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("first");
2080        config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("second");
2081        assert_eq!(
2082            config_values(&repo, "lfs.standalonetransferagent"),
2083            vec!["git-lfs-object-store".to_owned()],
2084        );
2085    }
2086
2087    #[test]
2088    fn config_set_replaces_differing_value() {
2089        let (repo, _dir) = empty_repo();
2090        let cwd = repo.workdir().expect("workdir");
2091        config_set(cwd, "lfs.standalonetransferagent", "old-name").expect("first");
2092        config_set(cwd, "lfs.standalonetransferagent", "new-name").expect("second");
2093        assert_eq!(
2094            config_values(&repo, "lfs.standalonetransferagent"),
2095            vec!["new-name".to_owned()],
2096        );
2097    }
2098
2099    #[test]
2100    fn config_set_collapses_legacy_duplicates() {
2101        // Simulate the on-disk state produced by older binaries that used
2102        // `--add` semantics for install/enable_debug: two entries for the
2103        // same key. `config_set` must collapse them to one canonical value.
2104        let (repo, _dir) = empty_repo();
2105        let cwd = repo.workdir().expect("workdir");
2106        config_add(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("seed 1");
2107        config_add(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("seed 2");
2108        assert_eq!(
2109            config_values(&repo, "lfs.standalonetransferagent").len(),
2110            2,
2111            "pre-condition: two duplicate entries",
2112        );
2113        config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("set");
2114        assert_eq!(
2115            config_values(&repo, "lfs.standalonetransferagent"),
2116            vec!["git-lfs-object-store".to_owned()],
2117        );
2118    }
2119
2120    #[test]
2121    fn config_set_idempotent_call_does_not_rewrite_file() {
2122        // Beyond byte-equality of values, the no-op fast path should leave
2123        // the file's bytes (and mtime) untouched. Asserting byte-equality
2124        // pins the optimization that `config_set_many` returns early when
2125        // nothing changed.
2126        let (repo, _dir) = empty_repo();
2127        let cwd = repo.workdir().expect("workdir");
2128        config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("first");
2129        let after_first = read_local_config(&repo);
2130        config_set(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("second");
2131        assert_eq!(read_local_config(&repo), after_first);
2132    }
2133
2134    #[test]
2135    fn config_set_many_writes_both_entries() {
2136        let (repo, _dir) = empty_repo();
2137        let entries: &[(&str, &str)] = &[
2138            (
2139                "lfs.customtransfer.git-lfs-object-store.path",
2140                "git-lfs-object-store",
2141            ),
2142            ("lfs.standalonetransferagent", "git-lfs-object-store"),
2143        ];
2144        config_set_many(repo.workdir().expect("workdir"), entries).expect("config_set_many");
2145        for (key, value) in entries {
2146            assert_eq!(config_values(&repo, key), vec![(*value).to_owned()]);
2147        }
2148    }
2149
2150    #[test]
2151    fn config_set_many_is_idempotent_across_all_entries() {
2152        // Direct simulation of `lfs::install::install` re-runs: the two
2153        // keys it writes must both have exactly one entry after two calls.
2154        let (repo, _dir) = empty_repo();
2155        let cwd = repo.workdir().expect("workdir");
2156        let entries: &[(&str, &str)] = &[
2157            (
2158                "lfs.customtransfer.git-lfs-object-store.path",
2159                "git-lfs-object-store",
2160            ),
2161            ("lfs.standalonetransferagent", "git-lfs-object-store"),
2162        ];
2163        config_set_many(cwd, entries).expect("first");
2164        config_set_many(cwd, entries).expect("second");
2165        for (key, value) in entries {
2166            assert_eq!(
2167                config_values(&repo, key),
2168                vec![(*value).to_owned()],
2169                "key {key:?} should have a single entry after two set_many calls",
2170            );
2171        }
2172    }
2173
2174    #[test]
2175    fn config_set_many_validates_all_entries_before_writing() {
2176        // Same guarantee as `config_add_many`: a malformed key anywhere in
2177        // the batch must abort before touching the file, so partial state
2178        // never lands on disk.
2179        let (repo, _dir) = empty_repo();
2180        let cwd = repo.workdir().expect("workdir");
2181        let before = read_local_config(&repo);
2182        let err = config_set_many(
2183            cwd,
2184            &[
2185                ("lfs.standalonetransferagent", "git-lfs-object-store"),
2186                ("nodot", "v"),
2187            ],
2188        )
2189        .expect_err("expected parse failure on second entry");
2190        assert!(matches!(err, GitError::ConfigKeyParse(_)), "got {err:?}");
2191        assert_eq!(read_local_config(&repo), before);
2192        assert!(
2193            config_values(&repo, "lfs.standalonetransferagent").is_empty(),
2194            "first entry should not have been written",
2195        );
2196    }
2197
2198    #[test]
2199    fn config_set_many_empty_input_is_noop() {
2200        let (repo, _dir) = empty_repo();
2201        let cwd = repo.workdir().expect("workdir");
2202        let before = read_local_config(&repo);
2203        config_set_many(cwd, &[]).expect("noop");
2204        assert_eq!(read_local_config(&repo), before);
2205    }
2206
2207    #[test]
2208    fn config_unset_if_present_removes_existing_value() {
2209        let (repo, _dir) = empty_repo();
2210        let cwd = repo.workdir().expect("workdir");
2211        config_add(cwd, "lfs.customtransfer.git-lfs-object-store.args", "debug").expect("seed");
2212        config_unset_if_present(cwd, "lfs.customtransfer.git-lfs-object-store.args")
2213            .expect("unset");
2214        assert!(config_values(&repo, "lfs.customtransfer.git-lfs-object-store.args").is_empty(),);
2215    }
2216
2217    #[test]
2218    fn config_unset_if_present_succeeds_when_key_absent() {
2219        // Issue #210: `disable-debug` re-runs must not error. The helper
2220        // swallows ConfigKeyNotSet for both the "section missing" and
2221        // "value missing within existing section" cases.
2222        let (repo, _dir) = empty_repo();
2223        let cwd = repo.workdir().expect("workdir");
2224        // Section missing entirely.
2225        config_unset_if_present(cwd, "lfs.never.set").expect("missing section is ok");
2226        // Section exists but the value doesn't.
2227        config_add(cwd, "lfs.standalonetransferagent", "git-lfs-object-store").expect("seed");
2228        config_unset_if_present(cwd, "lfs.othervalue").expect("missing value is ok");
2229        // The seeded value is still present.
2230        assert_eq!(
2231            config_values(&repo, "lfs.standalonetransferagent"),
2232            vec!["git-lfs-object-store".to_owned()],
2233        );
2234    }
2235
2236    #[test]
2237    fn config_unset_if_present_propagates_non_keynotset_errors() {
2238        // Malformed key must still surface as a parse error rather than
2239        // being silently swallowed alongside ConfigKeyNotSet.
2240        let (repo, _dir) = empty_repo();
2241        let err = config_unset_if_present(repo.workdir().expect("workdir"), "")
2242            .expect_err("expected parse error");
2243        assert!(matches!(err, GitError::ConfigKeyParse(_)), "got {err:?}");
2244    }
2245
2246    // --- shallow_boundaries / write_shallow_file ----------------------
2247
2248    #[test]
2249    fn shallow_boundaries_depth_one_returns_tip() {
2250        // Linear history a → b. With depth=1 the frontier is {b} (the tip
2251        // itself). Git writes b to .git/shallow so b appears parentless,
2252        // giving exactly 1 visible commit.
2253        let (repo, _dir) = empty_repo();
2254        let a = add_commit(&repo, "refs/heads/main", &[], "a");
2255        let b = add_commit(&repo, "refs/heads/main", &[a], "b");
2256        let tip = Sha::from_object_id(b);
2257        let bounds =
2258            shallow_boundaries(&repo, tip, NonZeroU32::new(1).unwrap()).expect("boundaries");
2259        assert_eq!(bounds, vec![b]);
2260    }
2261
2262    #[test]
2263    fn shallow_boundaries_returns_empty_when_history_shorter_than_depth() {
2264        // Single-commit history; depth=5 exhausts the graph and writes
2265        // no boundary (full clone).
2266        let (repo, _dir) = empty_repo();
2267        let a = add_commit(&repo, "refs/heads/main", &[], "a");
2268        let tip = Sha::from_object_id(a);
2269        let bounds =
2270            shallow_boundaries(&repo, tip, NonZeroU32::new(5).unwrap()).expect("boundaries");
2271        assert!(bounds.is_empty(), "expected empty, got {bounds:?}");
2272    }
2273
2274    #[test]
2275    fn shallow_boundaries_at_merge_returns_frontier_at_depth() {
2276        // Merge graph:
2277        //     M (tip, depth 1)
2278        //    / \
2279        //   A   B   (both depth 2 — the frontier)
2280        //    \ /
2281        //     C (depth 3 — excluded, not a boundary marker)
2282        //
2283        // BFS at depth=2 includes {M, A, B}. The frontier is {A, B};
2284        // both appear parentless in the shallow clone, giving 3 visible
2285        // commits. C is never visited and is not written to .git/shallow.
2286        let (repo, _dir) = empty_repo();
2287        let c = add_commit(&repo, "refs/heads/main", &[], "C");
2288        let a = add_commit(&repo, "refs/heads/main", &[c], "A");
2289        let b = add_commit(&repo, "refs/heads/side", &[c], "B");
2290        let m = add_commit(&repo, "refs/heads/main", &[a, b], "M");
2291        let tip = Sha::from_object_id(m);
2292        let bounds =
2293            shallow_boundaries(&repo, tip, NonZeroU32::new(2).unwrap()).expect("boundaries");
2294        let mut sorted = bounds.clone();
2295        sorted.sort_unstable();
2296        let mut expected = vec![a, b];
2297        expected.sort_unstable();
2298        assert_eq!(sorted, expected);
2299    }
2300
2301    #[test]
2302    fn shallow_boundaries_at_merge_with_depth_one_returns_tip() {
2303        // depth=1: the frontier is the merge tip itself. M appears
2304        // parentless, giving exactly 1 visible commit regardless of
2305        // how many parents it has.
2306        let (repo, _dir) = empty_repo();
2307        let a = add_commit(&repo, "refs/heads/main", &[], "A");
2308        let b = add_commit(&repo, "refs/heads/side", &[], "B");
2309        let m = add_commit(&repo, "refs/heads/main", &[a, b], "M");
2310        let tip = Sha::from_object_id(m);
2311        let bounds =
2312            shallow_boundaries(&repo, tip, NonZeroU32::new(1).unwrap()).expect("boundaries");
2313        assert_eq!(bounds, vec![m]);
2314    }
2315
2316    #[test]
2317    fn write_shallow_file_writes_boundaries_when_absent() {
2318        let (repo, dir) = empty_repo();
2319        let a = add_commit(&repo, "refs/heads/main", &[], "a");
2320        write_shallow_file(dir.path(), &[a]).expect("write");
2321        let path = repo.git_dir().join("shallow");
2322        let contents = std::fs::read_to_string(&path).expect("read shallow");
2323        assert_eq!(contents, format!("{a}\n"));
2324    }
2325
2326    #[test]
2327    fn write_shallow_file_dedupes_entries() {
2328        // Same SHA seeded and passed in the new boundaries: HashSet
2329        // dedup yields a single line. `a` is a root commit (no
2330        // parents), so the prune-by-ODB pass cannot reject it on
2331        // membership grounds — it lands in the file because it is in
2332        // `boundaries`.
2333        let (repo, dir) = empty_repo();
2334        let a = add_commit(&repo, "refs/heads/main", &[], "a");
2335        let path = repo.git_dir().join("shallow");
2336        std::fs::write(&path, format!("{a}\n")).expect("seed");
2337        write_shallow_file(dir.path(), &[a]).expect("write");
2338        let contents = std::fs::read_to_string(&path).expect("read");
2339        assert_eq!(contents, format!("{a}\n"));
2340    }
2341
2342    #[test]
2343    fn write_shallow_file_no_boundaries_no_existing_does_not_create_file() {
2344        // Empty boundaries + no existing file = no `.git/shallow`. A
2345        // fully cloned repo must not have this file.
2346        let (repo, dir) = empty_repo();
2347        let path = repo.git_dir().join("shallow");
2348        write_shallow_file(dir.path(), &[]).expect("noop");
2349        assert!(!path.exists(), "shallow file unexpectedly created");
2350    }
2351
2352    #[test]
2353    fn write_shallow_file_prunes_existing_when_parents_in_odb() {
2354        // The deepen scenario: `.git/shallow` previously held the
2355        // depth-1 tip; the deepening fetch installs the parent and
2356        // computes the new depth-N boundary. The old tip must be
2357        // pruned (its parent is now in the ODB), leaving only the new
2358        // boundary in the file.
2359        let (repo, dir) = empty_repo();
2360        let a = add_commit(&repo, "refs/heads/main", &[], "a");
2361        let b = add_commit(&repo, "refs/heads/main", &[a], "b");
2362        let path = repo.git_dir().join("shallow");
2363        std::fs::write(&path, format!("{b}\n")).expect("seed depth-1 tip");
2364        write_shallow_file(dir.path(), &[a]).expect("deepen");
2365        let contents = std::fs::read_to_string(&path).expect("read");
2366        assert_eq!(contents, format!("{a}\n"));
2367    }
2368
2369    #[test]
2370    fn write_shallow_file_unlinks_when_set_becomes_empty_after_pruning() {
2371        // Deepen-to-full-history: the existing tip's parents are now
2372        // in the ODB AND no new boundary is being added. The file
2373        // must be unlinked — its presence alone signals shallow
2374        // semantics to git, so a fully-deepened repo cannot keep it.
2375        let (repo, dir) = empty_repo();
2376        let a = add_commit(&repo, "refs/heads/main", &[], "a");
2377        let b = add_commit(&repo, "refs/heads/main", &[a], "b");
2378        let path = repo.git_dir().join("shallow");
2379        std::fs::write(&path, format!("{b}\n")).expect("seed");
2380        write_shallow_file(dir.path(), &[]).expect("deepen-to-full");
2381        assert!(!path.exists(), "shallow file should be unlinked");
2382    }
2383
2384    #[test]
2385    fn write_shallow_file_drops_existing_root_commit() {
2386        // A root commit has no parents, so the "all parents in ODB"
2387        // predicate is vacuously true — the entry is a no-op marker
2388        // and gets pruned. (`register_shallow` grafting a parentless
2389        // commit to parentlessness is a no-op anyway.)
2390        let (repo, dir) = empty_repo();
2391        let a = add_commit(&repo, "refs/heads/main", &[], "a");
2392        let b = add_commit(&repo, "refs/heads/main", &[a], "b");
2393        let path = repo.git_dir().join("shallow");
2394        std::fs::write(&path, format!("{a}\n")).expect("seed");
2395        write_shallow_file(dir.path(), &[b]).expect("write");
2396        let contents = std::fs::read_to_string(&path).expect("read");
2397        assert_eq!(contents, format!("{b}\n"));
2398    }
2399
2400    #[test]
2401    fn write_shallow_file_unlinks_when_only_existing_was_root() {
2402        let (repo, dir) = empty_repo();
2403        let a = add_commit(&repo, "refs/heads/main", &[], "a");
2404        let path = repo.git_dir().join("shallow");
2405        std::fs::write(&path, format!("{a}\n")).expect("seed");
2406        write_shallow_file(dir.path(), &[]).expect("write");
2407        assert!(!path.exists(), "shallow file should be unlinked");
2408    }
2409
2410    #[test]
2411    fn write_shallow_file_keeps_existing_when_a_parent_is_missing() {
2412        // Build a commit whose parent is a synthetic OID that was
2413        // never written to the ODB. The shallow entry must be kept
2414        // because its parent is not reachable locally — pruning it
2415        // would expose git to a dangling parent ref.
2416        let (repo, dir) = empty_repo();
2417        let synthetic_parent =
2418            ObjectId::from_hex(b"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").expect("synthetic OID");
2419        let orphan = commit_with_synthetic_parents(&repo, &[synthetic_parent], "orphan");
2420        let new_root = add_commit(&repo, "refs/heads/main", &[], "new_root");
2421        let path = repo.git_dir().join("shallow");
2422        std::fs::write(&path, format!("{orphan}\n")).expect("seed");
2423        write_shallow_file(dir.path(), &[new_root]).expect("write");
2424        let contents = std::fs::read_to_string(&path).expect("read");
2425        let mut expected = [format!("{orphan}"), format!("{new_root}")];
2426        expected.sort();
2427        assert_eq!(contents.trim(), expected.join("\n"));
2428    }
2429
2430    #[test]
2431    fn write_shallow_file_keeps_octopus_merge_when_any_parent_missing() {
2432        // Octopus merge with three parents, of which one is synthetic
2433        // (not in ODB). The entry stays in `.git/shallow` until ALL
2434        // parents are reachable; otherwise pruning would expose git
2435        // to a dangling parent.
2436        let (repo, dir) = empty_repo();
2437        let p1 = add_commit(&repo, "refs/heads/p1", &[], "p1");
2438        let p2 = add_commit(&repo, "refs/heads/p2", &[], "p2");
2439        let synthetic =
2440            ObjectId::from_hex(b"cafef00dcafef00dcafef00dcafef00dcafef00d").expect("synthetic");
2441        let merge = commit_with_synthetic_parents(&repo, &[p1, p2, synthetic], "octopus");
2442        let path = repo.git_dir().join("shallow");
2443        std::fs::write(&path, format!("{merge}\n")).expect("seed");
2444        write_shallow_file(dir.path(), &[]).expect("write");
2445        let contents = std::fs::read_to_string(&path).expect("read");
2446        assert_eq!(contents, format!("{merge}\n"));
2447    }
2448
2449    #[test]
2450    fn write_shallow_file_drops_entry_pointing_at_non_commit() {
2451        // A `.git/shallow` line that resolves to a tree (not a
2452        // commit) is stale; drop it.
2453        let (repo, dir) = empty_repo();
2454        let tree_id = make_marker_tree(&repo);
2455        let path = repo.git_dir().join("shallow");
2456        std::fs::write(&path, format!("{tree_id}\n")).expect("seed");
2457        write_shallow_file(dir.path(), &[]).expect("write");
2458        assert!(!path.exists(), "stale tree entry should not preserve file");
2459    }
2460
2461    #[test]
2462    fn write_shallow_file_drops_entry_missing_from_odb() {
2463        let (repo, dir) = empty_repo();
2464        let synthetic =
2465            ObjectId::from_hex(b"abcdef0123456789abcdef0123456789abcdef01").expect("synthetic");
2466        let path = repo.git_dir().join("shallow");
2467        std::fs::write(&path, format!("{synthetic}\n")).expect("seed");
2468        write_shallow_file(dir.path(), &[]).expect("write");
2469        assert!(!path.exists(), "missing-OID entry should not preserve file");
2470    }
2471
2472    // --- bundle / unbundle (native gix-pack) --------------------------
2473
2474    #[tokio::test]
2475    async fn bundle_unbundle_round_trips_natively() {
2476        let (src_repo, src_dir) = empty_repo();
2477        let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
2478        let sha = Sha::from_object_id(oid);
2479        let ref_name = RefName::new("refs/heads/main").expect("RefName");
2480
2481        let bundles = TempDir::new().expect("tempdir");
2482        let bundle_path = bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
2483            .await
2484            .expect("bundle");
2485        assert!(bundle_path.exists(), "bundle not written");
2486
2487        // Verify bundle v2 header format.
2488        let first_line = {
2489            use std::io::BufRead as _;
2490            let f = std::fs::File::open(&bundle_path).expect("open bundle");
2491            let mut buf = String::new();
2492            std::io::BufReader::new(f)
2493                .read_line(&mut buf)
2494                .expect("read");
2495            buf.trim_end().to_owned()
2496        };
2497        assert_eq!(first_line, "# v2 git bundle", "bundle magic mismatch");
2498
2499        let (dst_repo, _dst_dir) = empty_repo();
2500        unbundle(&dst_repo, bundles.path(), sha)
2501            .await
2502            .expect("unbundle");
2503        // `unbundle` copies pack objects into the destination ODB but does
2504        // not update refs — that's the remote-helper protocol's job.
2505        // Confirm via direct ODB lookup and via rev_parse.
2506        assert!(
2507            dst_repo
2508                .objects
2509                .clone()
2510                .into_inner()
2511                .contains(sha.as_object_id()),
2512            "commit object not in dst ODB after unbundle"
2513        );
2514        // Verify the OID is also resolvable via gix's spec parser — exercises
2515        // a different lookup path from contains(). The assert_eq on the
2516        // returned sha would be vacuous (resolve of a bare hex SHA always
2517        // returns that same SHA), so the .expect() is the assertion.
2518        branch::resolve(&dst_repo, &sha.to_string()).expect("resolve must work on bundled OID");
2519
2520        // Confirm that unbundle() removes the .keep file created by
2521        // write_to_directory. A lingering .keep prevents git-repack from
2522        // consolidating packs; this check would catch any regression that
2523        // stops the removal.
2524        let pack_dir = dst_repo.git_dir().join("objects/pack");
2525        let keep_files: Vec<_> = std::fs::read_dir(&pack_dir)
2526            .expect("read pack dir")
2527            .filter_map(Result::ok)
2528            .filter(|e| e.path().extension().is_some_and(|x| x == "keep"))
2529            .collect();
2530        assert!(
2531            keep_files.is_empty(),
2532            ".keep files not removed after unbundle: {keep_files:?}"
2533        );
2534        drop(src_dir);
2535    }
2536
2537    #[tokio::test]
2538    async fn bundle_includes_full_commit_history() {
2539        let (src_repo, src_dir) = empty_repo();
2540        let oid1 = add_commit(&src_repo, "refs/heads/main", &[], "first");
2541        let oid2 = add_commit(&src_repo, "refs/heads/main", &[oid1], "second");
2542        let sha = Sha::from_object_id(oid2);
2543        let ref_name = RefName::new("refs/heads/main").expect("RefName");
2544
2545        let bundles = TempDir::new().expect("tempdir");
2546        bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
2547            .await
2548            .expect("bundle");
2549
2550        let (dst_repo, _dst_dir) = empty_repo();
2551        unbundle(&dst_repo, bundles.path(), sha)
2552            .await
2553            .expect("unbundle");
2554
2555        // Both commits must be present in dst_repo ODB.
2556        let dst_odb = dst_repo.objects.clone().into_inner();
2557        assert!(
2558            dst_odb.contains(&oid1),
2559            "ancestor commit not in dst ODB after unbundle"
2560        );
2561        assert!(
2562            dst_odb.contains(&oid2),
2563            "tip commit not in dst ODB after unbundle"
2564        );
2565
2566        // Verify that trees and blobs (not just commits) are in the bundle.
2567        // add_commit always writes the same blob; write_blob is idempotent
2568        // (content-addressed), so this returns the same ID that add_commit
2569        // stored in src_repo without writing a second copy.
2570        let blob_id = src_repo.write_blob(b"hello\n").expect("blob id").detach();
2571        assert!(
2572            dst_odb.contains(&blob_id),
2573            "blob object not in dst ODB — ObjectExpansion::TreeContents may not be working"
2574        );
2575        drop(src_dir);
2576    }
2577
2578    // --- idempotency --------------------------------------------------
2579
2580    /// Calling `unbundle()` twice for the same SHA must succeed both times.
2581    ///
2582    /// On the second call `gix_pack::Bundle::write_to_directory` detects the
2583    /// pack already exists and returns `Outcome { keep_path: None, .. }` — the
2584    /// branch of our `.keep` removal logic that skips the `fs::remove_file`
2585    /// entirely. This test pins that path and guards against regressions that
2586    /// would return an error on a duplicate install.
2587    #[tokio::test]
2588    async fn unbundle_is_idempotent_on_duplicate_install() {
2589        let (src_repo, src_dir) = empty_repo();
2590        let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
2591        let sha = Sha::from_object_id(oid);
2592        let ref_name = RefName::new("refs/heads/main").expect("RefName");
2593
2594        let bundles = TempDir::new().expect("tempdir");
2595        bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
2596            .await
2597            .expect("bundle");
2598
2599        let (dst_repo, _dst_dir) = empty_repo();
2600
2601        unbundle(&dst_repo, bundles.path(), sha)
2602            .await
2603            .expect("first unbundle");
2604
2605        // Second unbundle of the same SHA: pack already on disk, so
2606        // write_to_directory returns keep_path = None. Must still return Ok(()).
2607        unbundle(&dst_repo, bundles.path(), sha)
2608            .await
2609            .expect("second unbundle (duplicate install)");
2610
2611        let pack_dir = dst_repo.git_dir().join("objects/pack");
2612        let keep_files: Vec<_> = std::fs::read_dir(&pack_dir)
2613            .expect("read pack dir")
2614            .filter_map(Result::ok)
2615            .filter(|e| e.path().extension().is_some_and(|x| x == "keep"))
2616            .collect();
2617        assert!(
2618            keep_files.is_empty(),
2619            ".keep files after duplicate unbundle: {keep_files:?}"
2620        );
2621
2622        assert!(
2623            dst_repo.objects.clone().into_inner().contains(&oid),
2624            "commit not in dst ODB after duplicate unbundle"
2625        );
2626        drop(src_dir);
2627    }
2628
2629    // --- concurrency --------------------------------------------------
2630
2631    /// Two concurrent `unbundle_at` calls for the same SHA must both succeed,
2632    /// leave no `.keep` files, and end with the object in the destination ODB.
2633    ///
2634    /// This exercises the `NotFound` handling in the `.keep` removal: the
2635    /// faster task removes the file; the slower task gets `NotFound` and must
2636    /// silently succeed rather than returning an error. The production fetch
2637    /// path (`fetch_batch`) runs bundle downloads in parallel and can reach
2638    /// this scenario when the same SHA appears in multiple concurrent fetch
2639    /// commands before `FetchedRefs` has recorded the first completion.
2640    #[tokio::test]
2641    async fn concurrent_unbundle_same_sha_is_idempotent() {
2642        let (src_repo, src_dir) = empty_repo();
2643        let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
2644        let sha = Sha::from_object_id(oid);
2645        let ref_name = RefName::new("refs/heads/main").expect("RefName");
2646
2647        let bundles = TempDir::new().expect("tempdir");
2648        bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
2649            .await
2650            .expect("bundle");
2651
2652        let (dst_repo, _dst_dir) = empty_repo();
2653        let dst_cwd = repo_cwd(&dst_repo).to_owned();
2654        let bundles_path = bundles.path().to_owned();
2655
2656        let (r1, r2) = tokio::join!(
2657            unbundle_at(&dst_cwd, &bundles_path, sha),
2658            unbundle_at(&dst_cwd, &bundles_path, sha),
2659        );
2660        assert!(r1.is_ok(), "first concurrent unbundle failed: {r1:?}");
2661        assert!(r2.is_ok(), "second concurrent unbundle failed: {r2:?}");
2662
2663        // No .keep files should survive regardless of task ordering.
2664        let pack_dir = dst_repo.git_dir().join("objects/pack");
2665        let keep_files: Vec<_> = std::fs::read_dir(&pack_dir)
2666            .expect("read pack dir")
2667            .filter_map(Result::ok)
2668            .filter(|e| e.path().extension().is_some_and(|x| x == "keep"))
2669            .collect();
2670        assert!(
2671            keep_files.is_empty(),
2672            ".keep files lingered after concurrent unbundle: {keep_files:?}"
2673        );
2674
2675        assert!(
2676            dst_repo.objects.clone().into_inner().contains(&oid),
2677            "commit not in dst ODB after concurrent unbundle"
2678        );
2679        drop(src_dir);
2680    }
2681
2682    // --- cross-tool bundle compatibility --------------------------------
2683
2684    /// Create a bundle with `git bundle create`, then verify our native
2685    /// unbundle can parse and install the objects.
2686    #[tokio::test]
2687    async fn git_bundle_create_readable_by_native_unbundle() {
2688        if !git_available() {
2689            eprintln!("skipping: git not on PATH");
2690            return;
2691        }
2692        let (src_repo, src_dir) = empty_repo();
2693        let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
2694        let sha = Sha::from_object_id(oid);
2695
2696        let bundles = TempDir::new().expect("tempdir");
2697        let bundle_path = bundles.path().join(format!("{sha}.bundle"));
2698
2699        let output = std::process::Command::new("git")
2700            .args(["bundle", "create"])
2701            .arg(&bundle_path)
2702            .arg("refs/heads/main")
2703            .current_dir(src_dir.path())
2704            .output()
2705            .expect("git bundle create");
2706        assert!(
2707            output.status.success(),
2708            "git bundle create failed:\n{}",
2709            String::from_utf8_lossy(&output.stderr)
2710        );
2711
2712        let (dst_repo, _dst_dir) = empty_repo();
2713        unbundle(&dst_repo, bundles.path(), sha)
2714            .await
2715            .expect("native unbundle of git-created bundle");
2716
2717        assert!(
2718            dst_repo.objects.clone().into_inner().contains(&oid),
2719            "commit not in dst ODB after native unbundle of git-created bundle"
2720        );
2721        drop(src_dir);
2722    }
2723
2724    /// Create a bundle with our native implementation, then verify that
2725    /// `git bundle verify` accepts the format and `git bundle unbundle`
2726    /// can install the objects into a git repository.
2727    #[tokio::test]
2728    async fn native_bundle_create_accepted_by_git() {
2729        if !git_available() {
2730            eprintln!("skipping: git not on PATH");
2731            return;
2732        }
2733        let (src_repo, src_dir) = empty_repo();
2734        let oid = add_commit(&src_repo, "refs/heads/main", &[], "first");
2735        let sha = Sha::from_object_id(oid);
2736        let ref_name = RefName::new("refs/heads/main").expect("RefName");
2737
2738        let bundles = TempDir::new().expect("tempdir");
2739        let bundle_path = bundle(&src_repo, bundles.path(), sha, ref_name.as_str())
2740            .await
2741            .expect("native bundle");
2742        drop(src_repo);
2743
2744        // `git bundle verify` validates the header format and pack checksum.
2745        let output = std::process::Command::new("git")
2746            .args(["bundle", "verify"])
2747            .arg(&bundle_path)
2748            .current_dir(src_dir.path())
2749            .output()
2750            .expect("git bundle verify");
2751        assert!(
2752            output.status.success(),
2753            "git bundle verify rejected our bundle:\n{}",
2754            String::from_utf8_lossy(&output.stderr)
2755        );
2756
2757        // `git bundle unbundle` installs the pack objects into a repository.
2758        let (dst_repo, dst_dir) = empty_repo();
2759        let output = std::process::Command::new("git")
2760            .args(["bundle", "unbundle"])
2761            .arg(&bundle_path)
2762            .current_dir(dst_dir.path())
2763            .output()
2764            .expect("git bundle unbundle");
2765        assert!(
2766            output.status.success(),
2767            "git bundle unbundle failed on native bundle:\n{}",
2768            String::from_utf8_lossy(&output.stderr)
2769        );
2770
2771        assert!(
2772            dst_repo.objects.clone().into_inner().contains(&oid),
2773            "commit not in dst ODB after git bundle unbundle of native bundle"
2774        );
2775        drop(src_dir);
2776    }
2777}