Skip to main content

repon_core/
git.rs

1//! The git backend. gix reads here; nothing in this module writes.
2//!
3//! Scoped to the probe path: the periodic fetch in `fetch.rs` always prunes, which
4//! mutates `refs/remotes/`, so it is a separate mutating path behind its own cargo
5//! feature rather than a claim this module makes
6//! ([ADR 0015](https://github.com/paulchiu/repon/blob/main/docs/adr/0015-the-core-owns-the-table.md)'s
7//! "The read-only invariant is scoped to the probe path").
8//!
9//! Private, and nothing here is re-exported yet: see the crate root doc comment.
10
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::sync::atomic::AtomicBool;
14
15use crate::entity::{AheadBehind, DirtyCounts, Head, Kind, SyncState};
16
17/// Error from a git read, cheap to clone because the whole state table is cloned
18/// every frame. A shared trait object was rejected: it gives no discriminant to
19/// branch on and nothing to serialise, and nothing in this crate reads a source chain.
20#[derive(Clone, Debug)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize))]
22pub enum ProbeError {
23    /// The path could not be opened as a git repository.
24    Open(Arc<str>),
25    /// An open repository's `HEAD` could not be read.
26    Read(Arc<str>),
27    /// A `.gitmodules` file existed but would not read or parse.
28    Submodules(Arc<str>),
29    /// The ancestry check between a branch and the default branch could not run:
30    /// a missing or corrupt commit, never a stand-in for "not an ancestor".
31    Ancestry(Arc<str>),
32    /// The patch-equivalence check could not run: a missing or corrupt commit
33    /// or tree, never a stand-in for "not equivalent".
34    PatchEquivalence(Arc<str>),
35    /// The ahead/behind comparison against a live upstream could not run: a
36    /// missing or corrupt commit, never a stand-in for zero.
37    AheadBehind(Arc<str>),
38    /// The behind-the-default-branch comparison could not run: a missing or
39    /// corrupt commit, never a stand-in for zero.
40    Base(Arc<str>),
41    /// Phase C's status read could not run: a platform that would not build, or
42    /// an iterator that errored partway through.
43    Status(Arc<str>),
44    /// The unpushed-commit count behind a `delete` confirm gate could not run: refs that
45    /// would not list, or a missing or corrupt commit, never a stand-in for zero.
46    Unpushed(Arc<str>),
47    /// `delete`'s phase 1 dirwalk enumerating ignored directories could not run: an index
48    /// that would not load, or a walk that errored partway through, never a stand-in for
49    /// "nothing is ignored".
50    IgnoredDirectories(Arc<str>),
51}
52
53impl std::fmt::Display for ProbeError {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            ProbeError::Open(message) => write!(f, "failed to open git repository: {message}"),
57            ProbeError::Read(message) => write!(f, "failed to read HEAD: {message}"),
58            ProbeError::Submodules(message) => write!(f, "failed to read .gitmodules: {message}"),
59            ProbeError::Ancestry(message) => write!(f, "failed to check ancestry: {message}"),
60            ProbeError::PatchEquivalence(message) => {
61                write!(f, "failed to check patch equivalence: {message}")
62            }
63            ProbeError::AheadBehind(message) => {
64                write!(f, "failed to compute ahead/behind counts: {message}")
65            }
66            ProbeError::Base(message) => {
67                write!(
68                    f,
69                    "failed to compute the behind-the-default-branch count: {message}"
70                )
71            }
72            ProbeError::Status(message) => write!(f, "failed to read status: {message}"),
73            ProbeError::Unpushed(message) => {
74                write!(f, "failed to count unpushed commits: {message}")
75            }
76            ProbeError::IgnoredDirectories(message) => {
77                write!(f, "failed to enumerate ignored directories: {message}")
78            }
79        }
80    }
81}
82
83impl std::error::Error for ProbeError {}
84
85/// The checked merge base of `a` and `b`: verifies both commit objects exist
86/// before asking gix, since [`gix::Repository::merge_base`] folds a missing
87/// commit object into the same `NotFound` it uses for two commits with no
88/// shared history, which would otherwise read as a confident "no common
89/// ancestor" rather than the read error it actually is. `Ok(None)` is that
90/// real "no shared history at all" answer (including `a == b`'s reflexive
91/// case, folded in early); `Err` is reserved for an actual read error, and is
92/// a plain `String` so each caller wraps it in its own [`ProbeError`] variant.
93pub(crate) fn checked_merge_base(
94    repo: &gix::Repository,
95    a: gix::ObjectId,
96    b: gix::ObjectId,
97) -> Result<Option<gix::ObjectId>, String> {
98    if a == b {
99        return Ok(Some(a));
100    }
101    for id in [a, b] {
102        if !repo.has_object(id) {
103            return Err(format!("commit object not found: {id}"));
104        }
105    }
106    match repo.merge_base(a, b) {
107        Ok(base) => Ok(Some(base.detach())),
108        Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None),
109        Err(other) => Err(other.to_string()),
110    }
111}
112
113/// Whether `repo` has any remote configured at all, read once from
114/// `Repository::remote_names()`. Reused by [`crate::default_branch::ChainFacts::resolve`]'s
115/// own rung-4 classification and by [`resolve_sync`], so the two never disagree about
116/// what "no remote" means; remote configuration is shared config, identical for a Repo
117/// and every Worktree attached to it, so a linked Worktree's own handle answers this
118/// exactly as its Repo's would.
119pub(crate) fn has_any_remote(repo: &gix::Repository) -> bool {
120    !repo.remote_names().is_empty()
121}
122
123/// Commits reachable from `tip` and not from `hidden` (and not from `hidden`'s own
124/// ancestry), the same shape as `git rev-list tip ^hidden --count`. Reflexive tips
125/// (`tip == hidden`) short-circuit to zero without a walk.
126fn commits_unique_to(
127    repo: &gix::Repository,
128    tip: gix::ObjectId,
129    hidden: gix::ObjectId,
130) -> Result<u32, String> {
131    if tip == hidden {
132        return Ok(0);
133    }
134    for id in [tip, hidden] {
135        if !repo.has_object(id) {
136            return Err(format!("commit object not found: {id}"));
137        }
138    }
139    let walk = repo
140        .rev_walk([tip])
141        .with_hidden([hidden])
142        .all()
143        .map_err(|error| error.to_string())?;
144    let mut count = 0u32;
145    for info in walk {
146        info.map_err(|error| error.to_string())?;
147        count += 1;
148    }
149    Ok(count)
150}
151
152/// `branch`'s ahead/behind counts against `upstream`: two rev-walks, each hidden
153/// behind the other's tip, the same shape as `git rev-list --left-right --count
154/// branch...upstream`. A plain `String` error, like [`checked_merge_base`]'s, so the
155/// one caller wraps it in its own [`ProbeError`] variant.
156pub(crate) fn ahead_behind(
157    repo: &gix::Repository,
158    branch: gix::ObjectId,
159    upstream: gix::ObjectId,
160) -> Result<AheadBehind, String> {
161    Ok(AheadBehind {
162        ahead: commits_unique_to(repo, branch, upstream)?,
163        behind: commits_unique_to(repo, upstream, branch)?,
164    })
165}
166
167/// The remote-tracking ref name `branch_name`'s configured upstream resolves to, or
168/// `None` when no upstream is configured for it at all. Shared by [`upstream_commit`]
169/// and [`crate::base`]'s own "is this row the default branch's own row" check, so the
170/// two never disagree about what a branch's upstream is.
171pub(crate) fn tracking_ref_name(
172    repo: &gix::Repository,
173    branch_name: &str,
174) -> Option<gix::refs::FullName> {
175    let full_name = gix::refs::FullName::try_from(format!("refs/heads/{branch_name}")).ok()?;
176    repo.branch_remote_tracking_ref_name(full_name.as_ref(), gix::remote::Direction::Fetch)?
177        .ok()
178}
179
180/// The commit a branch's configured upstream currently resolves to, or `None` when
181/// there is no upstream to compare against: no `branch.<name>.merge`/`.remote`
182/// configured, or a configured tracking ref that itself no longer resolves. Both
183/// causes settle to the same [`SyncState::NoUpstream`] at the call site, since
184/// neither has a count to show. `pub(crate)` rather than private: the auto-update
185/// needs the live commit itself, not only [`resolve_sync`]'s derived counts, to know
186/// what to fast-forward to.
187pub(crate) fn upstream_commit(repo: &gix::Repository, branch_name: &str) -> Option<gix::ObjectId> {
188    let tracking_ref_name = tracking_ref_name(repo, branch_name)?;
189    let mut reference = repo.find_reference(tracking_ref_name.as_ref()).ok()?;
190    reference.peel_to_id().ok().map(|id| id.detach())
191}
192
193/// `commit`'s count of commits behind `default_commit`: commits reachable from
194/// `default_commit` and not from `commit`. There is no ahead-of-default count
195/// ([default-branch.md](https://github.com/paulchiu/repon/blob/main/docs/spec/default-branch.md)'s
196/// "The two behind counts"): an ahead count there would only say the branch has
197/// commits of its own, which is not an integration signal.
198pub(crate) fn commits_behind(
199    repo: &gix::Repository,
200    commit: gix::ObjectId,
201    default_commit: gix::ObjectId,
202) -> Result<u32, String> {
203    commits_unique_to(repo, default_commit, commit)
204}
205
206/// Resolves the `sync` cell's value for one entity, per
207/// [layout-and-provenance.md](https://github.com/paulchiu/repon/blob/main/docs/spec/layout-and-provenance.md)'s
208/// "Glyphs": a Repo with no remote at all settles every one of its rows to
209/// [`SyncState::NoRemote`] before HEAD's own shape is even considered, since none of
210/// them can have an upstream either way; otherwise a row with no branch, or a branch
211/// with no live upstream, settles to [`SyncState::NoUpstream`]; otherwise the
212/// branch's ahead/behind counts against its upstream.
213pub(crate) fn resolve_sync(
214    repo: &gix::Repository,
215    head: Option<&Head>,
216) -> Result<SyncState, ProbeError> {
217    if !has_any_remote(repo) {
218        return Ok(SyncState::NoRemote);
219    }
220    let Some(Head::Branch { name, commit }) = head else {
221        return Ok(SyncState::NoUpstream);
222    };
223    let Some(upstream) = upstream_commit(repo, name) else {
224        return Ok(SyncState::NoUpstream);
225    };
226    ahead_behind(repo, *commit, upstream)
227        .map(SyncState::Tracking)
228        .map_err(|error| ProbeError::AheadBehind(error.into()))
229}
230
231/// Every commit on one of `repo`'s own local branches that no remote-tracking ref already
232/// carries, and how many local branches carry at least one: `git log --branches --not
233/// --remotes`, counted whole and again per branch.
234///
235/// Read against the remote-tracking refs rather than each branch's configured upstream, so a
236/// branch that was never pushed and has no upstream at all counts every commit of its own
237/// rather than none. A Repo with no remote therefore has its whole history unpushed, which is
238/// exactly the case [ADR 0028](https://github.com/paulchiu/repon/blob/main/docs/adr/0028-repon-writes-the-repo-entries-it-owns.md)
239/// names as the one that actually loses work when a working tree is deleted.
240pub(crate) fn unpushed(repo: &gix::Repository) -> Result<(u32, u32), ProbeError> {
241    let remote_tips = branch_tips(repo, Branches::Remote)?;
242    let local_tips = branch_tips(repo, Branches::Local)?;
243    if local_tips.is_empty() {
244        return Ok((0, 0));
245    }
246
247    let mut branches = 0u32;
248    for tip in &local_tips {
249        if commits_not_carried_by(repo, &[*tip], &remote_tips)? > 0 {
250            branches += 1;
251        }
252    }
253    let commits = commits_not_carried_by(repo, &local_tips, &remote_tips)?;
254    Ok((commits, branches))
255}
256
257/// Which half of the ref namespace [`branch_tips`] reads.
258#[derive(Debug, Clone, Copy)]
259enum Branches {
260    Local,
261    Remote,
262}
263
264/// Every commit `tips` reaches that `hidden` does not, the same shape as `git rev-list tips
265/// --not hidden --count`. An empty `hidden` hides nothing, which is what makes a Repo with no
266/// remote count its whole history.
267fn commits_not_carried_by(
268    repo: &gix::Repository,
269    tips: &[gix::ObjectId],
270    hidden: &[gix::ObjectId],
271) -> Result<u32, ProbeError> {
272    let walk = repo
273        .rev_walk(tips.iter().copied())
274        .with_hidden(hidden.iter().copied())
275        .all()
276        .map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
277    let mut count = 0u32;
278    for info in walk {
279        info.map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
280        count += 1;
281    }
282    Ok(count)
283}
284
285/// The commit every branch in one half of the ref namespace points at. A ref that will not
286/// peel (a symbolic `refs/remotes/origin/HEAD`, or a tag object where a commit was expected)
287/// is skipped rather than failing the whole read: it names a commit some other ref in the
288/// same half already names.
289fn branch_tips(repo: &gix::Repository, which: Branches) -> Result<Vec<gix::ObjectId>, ProbeError> {
290    let platform = repo
291        .references()
292        .map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
293    let iter = match which {
294        Branches::Local => platform.local_branches(),
295        Branches::Remote => platform.remote_branches(),
296    }
297    .map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
298
299    let mut tips = Vec::new();
300    for reference in iter {
301        let mut reference =
302            reference.map_err(|error| ProbeError::Unpushed(error.to_string().into()))?;
303        if let Ok(id) = reference.peel_to_id() {
304            tips.push(id.detach());
305        }
306    }
307    Ok(tips)
308}
309
310/// One of the ten shapes an in-progress git operation can take, one to one with
311/// gix's own `state::InProgress`
312/// ([ADR 0019](https://github.com/paulchiu/repon/blob/main/docs/adr/0019-a-detached-head-is-a-shape-of-head-not-a-worktree-state.md)).
313/// Read from `Repository::state()`, which stats the per-worktree git dir's own
314/// marker files rather than any Cell this crate probes, so it carries no
315/// provenance of its own: it is a fact of the moment it was read, not a value
316/// that can go stale or fail to resolve.
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318#[cfg_attr(feature = "serde", derive(serde::Serialize))]
319pub enum InProgressOperation {
320    ApplyMailbox,
321    ApplyMailboxRebase,
322    Bisect,
323    CherryPick,
324    CherryPickSequence,
325    Merge,
326    Rebase,
327    RebaseInteractive,
328    Revert,
329    RevertSequence,
330}
331
332/// Reads `repo`'s in-progress operation, or `None` while none is running.
333/// Measured in [ADR 0019](https://github.com/paulchiu/repon/blob/main/docs/adr/0019-a-detached-head-is-a-shape-of-head-not-a-worktree-state.md)
334/// at 6.55ms across 403 entities, so this rides along with the rest of Phase A
335/// rather than getting its own probe phase.
336pub(crate) fn in_progress_operation(repo: &gix::Repository) -> Option<InProgressOperation> {
337    match repo.state()? {
338        gix::state::InProgress::ApplyMailbox => Some(InProgressOperation::ApplyMailbox),
339        gix::state::InProgress::ApplyMailboxRebase => Some(InProgressOperation::ApplyMailboxRebase),
340        gix::state::InProgress::Bisect => Some(InProgressOperation::Bisect),
341        gix::state::InProgress::CherryPick => Some(InProgressOperation::CherryPick),
342        gix::state::InProgress::CherryPickSequence => Some(InProgressOperation::CherryPickSequence),
343        gix::state::InProgress::Merge => Some(InProgressOperation::Merge),
344        gix::state::InProgress::Rebase => Some(InProgressOperation::Rebase),
345        gix::state::InProgress::RebaseInteractive => Some(InProgressOperation::RebaseInteractive),
346        gix::state::InProgress::Revert => Some(InProgressOperation::Revert),
347        gix::state::InProgress::RevertSequence => Some(InProgressOperation::RevertSequence),
348    }
349}
350
351/// One commit in an entity's recent history: its seven-character abbreviated id
352/// and its message's first line. Carries no provenance of its own, the same
353/// reasoning as [`InProgressOperation`]: it is read fresh alongside `branch`
354/// rather than tracked as a Cell.
355#[derive(Debug, Clone, PartialEq, Eq)]
356#[cfg_attr(feature = "serde", derive(serde::Serialize))]
357pub struct RecentCommit {
358    pub short_id: Arc<str>,
359    pub summary: Arc<str>,
360}
361
362/// Up to `limit` commits reachable from `repo`'s current HEAD, most recent
363/// first. Empty on an unborn HEAD, which has no commit to walk from, and empty
364/// (rather than an error) on any other read failure: this is supplementary
365/// context for the detail pane, not a Cell whose provenance a caller needs to
366/// read.
367pub(crate) fn recent_commits(repo: &gix::Repository, limit: usize) -> Vec<RecentCommit> {
368    let Ok(head_commit) = repo.head_commit() else {
369        return Vec::new();
370    };
371    let Ok(walk) = head_commit.id().ancestors().all() else {
372        return Vec::new();
373    };
374
375    let mut commits = Vec::new();
376    for info in walk.take(limit) {
377        let Ok(info) = info else { break };
378        let short_id = info.id.to_string().chars().take(7).collect::<String>();
379        let summary = repo
380            .find_object(info.id)
381            .ok()
382            .and_then(|object| object.try_into_commit().ok())
383            .and_then(|commit| {
384                commit
385                    .message()
386                    .ok()
387                    .map(|message| message.summary().to_string())
388            })
389            .unwrap_or_default();
390        commits.push(RecentCommit {
391            short_id: Arc::from(short_id),
392            summary: Arc::from(summary),
393        });
394    }
395    commits
396}
397
398/// One name and working-tree-relative path an entity's own `.gitmodules` names.
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub(crate) struct SubmoduleEntry {
401    pub name: Arc<str>,
402    pub relative_path: PathBuf,
403}
404
405/// What opening one discovered boundary reveals about its own identity: which of
406/// Repo or Worktree it is, the common dir it shares with every Worktree attached to
407/// the same Repo, and the Submodules its own `.gitmodules` names.
408///
409/// `repo` is the same open handle this function already paid for `gix::open` to
410/// produce, converted to the thread-safe form: `Core::start` caches it so the
411/// entity's own phase A probe derives its per-task handle from this one instead
412/// of opening the repository a second time.
413pub(crate) struct Resolved {
414    pub kind: Kind,
415    pub common_dir: Arc<Path>,
416    pub submodules: Result<Vec<SubmoduleEntry>, ProbeError>,
417    pub repo: gix::ThreadSafeRepository,
418}
419
420/// Reads everything discovery's second half needs from an already-open `repo`:
421/// its own Kind and common dir, from gix's own worktree and `commondir`
422/// resolution rather than this crate re-deriving the `.git` file and `commondir`
423/// file formats by hand, plus its Submodules.
424///
425/// Split out from [`resolve_boundary`] so a boundary discovery already has a
426/// cached [`gix::ThreadSafeRepository`] for (because a Generation earlier than
427/// this one already opened it) can be resolved again without a second
428/// `gix::open`, which is what lets discovery re-run every Generation
429/// ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md))
430/// without paying the open cost every time.
431pub(crate) fn resolve_from_open(repo: gix::Repository) -> Resolved {
432    let kind = match repo.kind() {
433        gix::repository::Kind::LinkedWorkTree => Kind::Worktree,
434        gix::repository::Kind::Common | gix::repository::Kind::Submodule => Kind::Repo,
435    };
436    let common_dir = repo.common_dir();
437    let common_dir: Arc<Path> =
438        Arc::from(std::fs::canonicalize(common_dir).unwrap_or_else(|_| common_dir.to_path_buf()));
439    let submodules = read_gitmodules(&repo).map(|entries| entries.unwrap_or_default());
440    Resolved {
441        kind,
442        common_dir,
443        submodules,
444        repo: repo.into_sync(),
445    }
446}
447
448/// Opens `path` and resolves it via [`resolve_from_open`]. The first-time path:
449/// every caller with no cached handle for `path` yet comes through here.
450pub(crate) fn resolve_boundary(path: &Path) -> Result<Resolved, ProbeError> {
451    let repo = gix::open(path).map_err(|error| ProbeError::Open(error.to_string().into()))?;
452    Ok(resolve_from_open(repo))
453}
454
455/// Opens `path` and returns its git common dir, canonicalized, with nothing else
456/// `resolve_boundary` also reads (Kind, Submodules): a `[[repo]]` override's own
457/// `path` only ever needs this one fact to key its match, per
458/// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries).
459pub(crate) fn common_dir_of(path: &Path) -> Result<Arc<Path>, ProbeError> {
460    let repo = gix::open(path).map_err(|error| ProbeError::Open(error.to_string().into()))?;
461    let common_dir = repo.common_dir();
462    Ok(Arc::from(
463        std::fs::canonicalize(common_dir).unwrap_or_else(|_| common_dir.to_path_buf()),
464    ))
465}
466
467/// The size of gix's decoded-object cache on every handle repon opens, in bytes.
468///
469/// gix leaves this off by default (an unset `gitoxide.objects.cacheLimit` parses to 0,
470/// which `setup_objects` reads as "no cache"), and its own docs ask for one on every
471/// rev-walk and tree-diff entry point. Only one of repon's phases is such an entry point
472/// in a way that pays: phase D's patch equivalence, whose `scan_default_branch` diffs each
473/// commit on the default branch against its own first parent, so consecutive iterations
474/// decode the same tree twice and every subtree the two commits share on both sides.
475///
476/// Measured, per [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
477/// "The fan-out shape": phase D over the owner's real population goes from 878-1025ms
478/// uncached to 475-560ms cached, at every pool width tried, while the cheap phases and the
479/// status walk do not move at all (0.8%, inside noise). Every cap between 1MiB and 64MiB
480/// lands at that same floor, so the number is not a tuned optimum and nothing here depends
481/// on it being 4: it is the smallest cap with headroom over the ~2MB per-handle high-water
482/// mark actually observed, and the cache grows lazily to a ceiling rather than reserving,
483/// so a cap is a bound on the worst case rather than memory spent up front.
484const OBJECT_CACHE_BYTES: usize = 4 * 1024 * 1024;
485
486/// Opens `path` as a git repository and hands back the thread-safe form.
487///
488/// `gix::Repository` holds a `RefCell` free-list of buffers, so it is `Send` but
489/// not `Sync`; `gix::ThreadSafeRepository` is `Send`, `Sync` and `Clone`
490/// ([core-api.md](https://github.com/paulchiu/repon/blob/main/docs/spec/core-api.md)'s
491/// "Threads and lifecycle"). Every caller that wants to probe from more than one
492/// task opens through here once and has each task derive its own `Repository` via
493/// [`gix::ThreadSafeRepository::to_thread_local`], never sharing one `Repository`
494/// across tasks.
495///
496/// The object cache is set here, at open time, rather than through
497/// `Repository::object_cache_size` on a derived handle: that method takes `&mut self` and
498/// the cache lives on the handle, so a later `to_thread_local` would get a fresh one with
499/// no cache at all. gix re-runs `setup_objects` from the stored config on every handle it
500/// derives, so a config override applied once here is the only form every generation's
501/// handles inherit. It is an override rather than a default, so it also wins over a
502/// `gitoxide.objects.cacheLimit` in the user's own git config; that key is gitoxide-specific
503/// and repon has measured its own value for it ([`OBJECT_CACHE_BYTES`]).
504pub(crate) fn open_thread_safe(path: &Path) -> Result<gix::ThreadSafeRepository, ProbeError> {
505    let options = gix::open::Options::default()
506        .config_overrides([format!("gitoxide.objects.cacheLimit={OBJECT_CACHE_BYTES}")]);
507    gix::ThreadSafeRepository::open_opts(path, options)
508        .map_err(|error| ProbeError::Open(error.to_string().into()))
509}
510
511/// Reads `repo`'s own `.gitmodules`, one level deep, or `None` where none exists.
512///
513/// `Repository::open_modules_file` stats the worktree file itself and never falls
514/// back to the index or `HEAD`, so an entity with no `.gitmodules` costs one stat
515/// and never opens a submodule reader; [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)
516/// records `Repository::modules()`'s fallback (loading the whole index, then
517/// peeling `HEAD`) as the cost this avoids by never calling it. Per that spec, gix
518/// treats a `.gitmodules` that is a symlink as absent.
519fn read_gitmodules(repo: &gix::Repository) -> Result<Option<Vec<SubmoduleEntry>>, ProbeError> {
520    let Some(modules) = repo
521        .open_modules_file()
522        .map_err(|error| ProbeError::Submodules(error.to_string().into()))?
523    else {
524        return Ok(None);
525    };
526
527    let mut entries = Vec::new();
528    for name in modules.names() {
529        let relative_path = modules
530            .path(name)
531            .map_err(|error| ProbeError::Submodules(error.to_string().into()))?;
532        entries.push(SubmoduleEntry {
533            name: Arc::from(name.to_string()),
534            relative_path: gix::path::from_bstring(relative_path),
535        });
536    }
537    Ok(Some(entries))
538}
539
540/// Reads `HEAD` from an already-open `repo` and maps it onto the crate's own
541/// three-shape [`Head`], one to one with gix's `head::Kind`.
542///
543/// This is Phase A, [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
544/// cheapest and least contended read. `repo` is a per-task handle derived from a
545/// shared [`gix::ThreadSafeRepository`] via `to_thread_local`, never one shared
546/// across tasks, because `gix::Repository` is `Send` but not `Sync`. A `HEAD` that
547/// will not read at all is `Err` here, checked before any shape is classified, so
548/// it can never surface as Detached or Unborn.
549pub fn head_shape(repo: &gix::Repository) -> Result<Head, ProbeError> {
550    let head = repo
551        .head()
552        .map_err(|error| ProbeError::Read(error.to_string().into()))?;
553    let commit = head.id().map(|id| id.detach());
554    Ok(match head.kind {
555        gix::head::Kind::Symbolic(reference) => {
556            let Some(commit) = commit else {
557                // An attached, born HEAD always has a commit to peel; reached only if
558                // that invariant breaks.
559                return Err(ProbeError::Read(
560                    "attached HEAD resolved no commit".to_string().into(),
561                ));
562            };
563            Head::Branch {
564                name: Arc::from(reference.name.shorten().to_string()),
565                commit,
566            }
567        }
568        gix::head::Kind::Unborn(name) => Head::Unborn(Arc::from(name.shorten().to_string())),
569        gix::head::Kind::Detached { target, peeled } => Head::Detached(peeled.unwrap_or(target)),
570    })
571}
572
573/// Phase C's typed counts against `repo`'s index and working tree, per
574/// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s "The
575/// phases": the whole of the cost, and the only phase whose interruption point actually
576/// matters, so `cancel` is handed straight to gix rather than merely checked before the read
577/// starts the way [`head_shape`] and [`resolve_sync`] check theirs.
578///
579/// Deliberately the index-to-worktree comparison alone
580/// ([`gix::Repository::status`]'s `into_index_worktree_iter`), never the head-to-index half a
581/// full [`gix::Repository::is_dirty`] also runs: that second pass is what the boolean check
582/// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md) rejected pays
583/// for redundantly on a population that is 96% clean, and it is why typed counting measured
584/// cheaper than the boolean check it replaces despite counting rather than short-circuiting.
585///
586/// The status platform's thread limit is pinned to 1, per [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
587/// "The fan-out shape": one rayon task per entity already claims a core each, so leaving gix
588/// free to spawn its own would oversubscribe by an order of magnitude.
589pub(crate) fn dirty_counts(
590    repo: &gix::Repository,
591    cancel: Arc<AtomicBool>,
592) -> Result<DirtyCounts, ProbeError> {
593    // scan: dirty-counts-cancel begin
594    let platform = repo
595        .status(gix::progress::Discard)
596        .map_err(|error| ProbeError::Status(error.to_string().into()))?
597        .index_worktree_options_mut(|options| options.thread_limit = Some(1))
598        .should_interrupt_owned(cancel);
599    // scan: dirty-counts-cancel end
600    let iter = platform
601        .into_index_worktree_iter(Vec::new())
602        .map_err(|error| ProbeError::Status(error.to_string().into()))?;
603
604    let mut counts = DirtyCounts::default();
605    for item in iter {
606        let item = item.map_err(|error| ProbeError::Status(error.to_string().into()))?;
607        classify_index_worktree_item(&item, &mut counts);
608    }
609    Ok(counts)
610}
611
612/// How many linked Worktrees point into `repo`, read from git's own
613/// `<common dir>/worktrees` register rather than from any table of discovered entities: one
614/// living outside the active Set's roots is still destroyed by deleting the Repo it is linked
615/// from ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
616/// confirm gate). The main worktree is never among them, which is why a Repo with no linked
617/// Worktree at all counts zero.
618pub(crate) fn linked_worktrees(repo: &gix::Repository) -> Result<u32, ProbeError> {
619    repo.worktrees()
620        .map(|worktrees| worktrees.len() as u32)
621        .map_err(|error| ProbeError::Read(error.to_string().into()))
622}
623
624/// Every linked Worktree's own working directory pointing into `repo`, read the same way
625/// [`linked_worktrees`] counts them. A base that will not read (a corrupt `gitdir` file) is
626/// dropped rather than failing the whole list, since a Worktree this broken cannot be acted
627/// on by path anyway.
628///
629/// What deleting a Repo needs to also remove
630/// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
631/// "Deleting a Repo also takes its linked Worktrees with it"): each linked Worktree's own
632/// directory sits outside the Repo's, so removing the Repo's working tree alone never
633/// touches it.
634pub(crate) fn linked_worktree_paths(repo: &gix::Repository) -> Result<Vec<PathBuf>, ProbeError> {
635    Ok(repo
636        .worktrees()
637        .map_err(|error| ProbeError::Read(error.to_string().into()))?
638        .into_iter()
639        .filter_map(|worktree| worktree.base().ok())
640        .collect())
641}
642
643/// The administrative directory `git worktree remove` deletes for the linked Worktree
644/// `repo` was opened from: its own git dir, distinct from [`gix::Repository::common_dir`],
645/// which names the shared object store instead
646/// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
647/// "What `delete` does to a Worktree").
648pub(crate) fn worktree_admin_dir(repo: &gix::Repository) -> PathBuf {
649    repo.git_dir().to_path_buf()
650}
651
652/// `repo`'s own ignored directories, collapsed to their own root rather than walked file by
653/// file: `delete`'s phase 1
654/// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
655/// "Deleting a working tree").
656///
657/// gix's dirwalk stops descending the moment it classifies a directory as ignored
658/// ([`gix::dir::walk::ForDeletionMode::IgnoredDirectoriesCanHideNestedRepositories`], the
659/// default it is set to here), so a `node_modules` with tens of thousands of files inside is
660/// one returned entry, never one per file, and the walk pays only for the tracked and
661/// untracked part of the tree, which is the small part. `EmissionMode::CollapseDirectory` on
662/// `emit_ignored` is what turns "every ignored file" into "the directory holding them";
663/// `for_deletion` is set for the same call, which gix requires so it does not collapse a
664/// directory holding a precious file.
665///
666/// A bare repository has no working tree to walk, so this returns an empty list rather than
667/// erroring: there is nothing for `delete`'s phase 2 to act on either way.
668pub(crate) fn ignored_directories_for_deletion(
669    repo: &gix::Repository,
670) -> Result<Vec<PathBuf>, ProbeError> {
671    if repo.workdir().is_none() {
672        return Ok(Vec::new());
673    }
674    let index = repo
675        .index_or_load_from_head_or_empty()
676        .map_err(|error| ProbeError::IgnoredDirectories(error.to_string().into()))?;
677    let options = repo
678        .dirwalk_options()
679        .map_err(|error| ProbeError::IgnoredDirectories(error.to_string().into()))?
680        .emit_ignored(Some(gix::dir::walk::EmissionMode::CollapseDirectory))
681        .for_deletion(Some(
682            gix::dir::walk::ForDeletionMode::IgnoredDirectoriesCanHideNestedRepositories,
683        ));
684    let should_interrupt = AtomicBool::new(false);
685    let mut ignored = IgnoredEntries::default();
686    let outcome = repo
687        .dirwalk(
688            &index,
689            Vec::<&str>::new(),
690            &should_interrupt,
691            options,
692            &mut ignored,
693        )
694        .map_err(|error| ProbeError::IgnoredDirectories(error.to_string().into()))?;
695    Ok(ignored
696        .rela_paths
697        .into_iter()
698        .map(|rela_path| {
699            outcome
700                .traversal_root
701                .join(gix::path::from_bstring(rela_path))
702        })
703        .collect())
704}
705
706/// A [`gix::dir::walk::Delegate`] that keeps only the ignored entries a walk emits, rather
707/// than every entry the built-in `Collect` delegate would buffer (tracked and untracked
708/// entries included): [`ignored_directories_for_deletion`] has no use for either.
709#[derive(Default)]
710struct IgnoredEntries {
711    rela_paths: Vec<gix::bstr::BString>,
712}
713
714impl gix::dir::walk::Delegate for IgnoredEntries {
715    fn emit(
716        &mut self,
717        entry: gix::dir::EntryRef<'_>,
718        _collapsed_directory_status: Option<gix::dir::entry::Status>,
719    ) -> gix::dir::walk::Action {
720        if matches!(entry.status, gix::dir::entry::Status::Ignored(_)) {
721            self.rela_paths.push(entry.rela_path.into_owned());
722        }
723        std::ops::ControlFlow::Continue(())
724    }
725}
726
727/// Whether `repo`'s index differs from `HEAD`: the half [`dirty_counts`] deliberately does
728/// not run, and the one a `git add` with no commit lands in
729/// ([repo-management.md](https://github.com/paulchiu/repon/blob/main/docs/spec/repo-management.md)'s
730/// confirm gate: staged work is "the case most easily lost, because it looks clean to any
731/// check that compares only the index against the worktree").
732///
733/// Stops at the first change rather than counting, since the gate asks a yes-or-no question;
734/// an unborn `HEAD` compares against the empty tree, so the first `git add` in a repository
735/// with no commit yet answers `true` rather than erroring.
736pub(crate) fn staged_changes(repo: &gix::Repository) -> Result<bool, ProbeError> {
737    let head_tree = repo
738        .head_tree_id_or_empty()
739        .map_err(|error| ProbeError::Status(error.to_string().into()))?;
740    let index = repo
741        .index_or_empty()
742        .map_err(|error| ProbeError::Status(error.to_string().into()))?;
743    let mut staged = false;
744    repo.tree_index_status(
745        &head_tree,
746        &index,
747        None,
748        gix::status::tree_index::TrackRenames::Disabled,
749        |_, _, _| {
750            staged = true;
751            Ok::<_, std::convert::Infallible>(std::ops::ControlFlow::Break(()))
752        },
753    )
754    .map_err(|error| ProbeError::Status(error.to_string().into()))?;
755    Ok(staged)
756}
757
758/// Folds one [`gix::status::index_worktree::Item`] into `counts`. Exhaustive over the
759/// item shape and, for a tracked change, over [`gix::status::plumbing::index_as_worktree::EntryStatus`]
760/// and its own [`gix::status::plumbing::index_as_worktree::Change`]: a variant gix adds to
761/// either later must be classified here or this fails to compile, rather than silently
762/// widening or narrowing a count.
763fn classify_index_worktree_item(
764    item: &gix::status::index_worktree::Item,
765    counts: &mut DirtyCounts,
766) {
767    use gix::status::index_worktree::Item;
768    use gix::status::plumbing::index_as_worktree::{Change, EntryStatus};
769
770    match item {
771        Item::Modification { status, .. } => match status {
772            EntryStatus::Conflict { .. } => counts.modified += 1,
773            EntryStatus::Change(change) => match change {
774                Change::Removed => counts.deleted += 1,
775                Change::Type { .. } => counts.modified += 1,
776                Change::Modification { .. } => counts.modified += 1,
777                Change::SubmoduleModification(_) => counts.modified += 1,
778            },
779            // Neither a real content change nor a missing file: an entry whose stat needs
780            // refreshing, or one added with `git add --intent-to-add` and not yet written.
781            EntryStatus::NeedsUpdate(_) | EntryStatus::IntentToAdd => {}
782        },
783        Item::DirectoryContents { entry, .. } => match entry.status {
784            gix::dir::entry::Status::Untracked => counts.untracked += 1,
785            // The default dirwalk already excludes ignored and pruned paths, matching
786            // `git status --ignored=no`; matched here rather than assumed, so a dirwalk
787            // option this crate never sets cannot silently miscount.
788            gix::dir::entry::Status::Tracked
789            | gix::dir::entry::Status::Ignored(_)
790            | gix::dir::entry::Status::Pruned => {}
791        },
792        // A rename or copy the rewrite tracker matched: this crate never turns rewrite
793        // tracking on ([`dirty_counts`] leaves `Platform`'s renames at their default), so this
794        // arm exists for exhaustiveness rather than a live case. Counted as one modification,
795        // the same as git's own `git status` porcelain, which shows a rename as a single line.
796        Item::Rewrite { .. } => counts.modified += 1,
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use crate::test_support::{git, head_sha};
804
805    /// The path-taking shape most tests want: opens `path` fresh through the same
806    /// shared-handle path production uses (`open_thread_safe` then
807    /// `to_thread_local`) rather than calling `gix::open` directly, so a test
808    /// exercises the real seam.
809    fn head_shape_at(path: &Path) -> Result<Head, ProbeError> {
810        let repo = open_thread_safe(path)?;
811        head_shape(&repo.to_thread_local())
812    }
813
814    #[test]
815    fn a_freshly_initialised_repository_is_unborn() {
816        let dir = tempfile::tempdir().expect("temp dir");
817        gix::init(dir.path()).expect("init");
818
819        let head = head_shape_at(dir.path()).expect("read HEAD");
820
821        assert!(matches!(head, Head::Unborn(_)));
822    }
823
824    #[test]
825    fn a_commit_on_a_branch_reads_as_attached() {
826        let dir = tempfile::tempdir().expect("temp dir");
827        gix::init(dir.path()).expect("init");
828        git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
829
830        let head = head_shape_at(dir.path()).expect("read HEAD");
831
832        match head {
833            Head::Branch { name, .. } => assert!(!name.is_empty()),
834            other => panic!("expected an attached branch, got {other:?}"),
835        }
836    }
837
838    /// The environment contract's `REPON_HEAD` needs this: an attached branch's
839    /// commit, not only a detached HEAD's.
840    #[test]
841    fn an_attached_branch_carries_its_own_resolved_commit() {
842        let dir = tempfile::tempdir().expect("temp dir");
843        gix::init(dir.path()).expect("init");
844        git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
845        let sha = crate::test_support::head_sha(dir.path());
846
847        let head = head_shape_at(dir.path()).expect("read HEAD");
848
849        match head {
850            Head::Branch { commit, .. } => assert_eq!(commit.to_string(), sha),
851            other => panic!("expected an attached branch, got {other:?}"),
852        }
853    }
854
855    #[test]
856    fn a_detached_checkout_carries_the_commit_and_no_name() {
857        let dir = tempfile::tempdir().expect("temp dir");
858        gix::init(dir.path()).expect("init");
859        git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
860        git(dir.path(), &["checkout", "--detach", "HEAD"]);
861
862        let head = head_shape_at(dir.path()).expect("read HEAD");
863
864        assert!(matches!(head, Head::Detached(_)));
865    }
866
867    #[test]
868    fn a_directory_that_is_not_a_repo_is_an_error() {
869        let dir = tempfile::tempdir().expect("temp dir");
870
871        assert!(matches!(
872            head_shape_at(dir.path()),
873            Err(ProbeError::Open(_))
874        ));
875    }
876
877    /// A `HEAD` that opens fine but will not parse must fail rather than being
878    /// misread as Detached or Unborn: this is the check the whole crate leans on
879    /// to keep a broken repository off the two settled shapes.
880    #[test]
881    fn a_head_file_that_will_not_parse_is_a_failure_not_a_shape() {
882        let dir = tempfile::tempdir().expect("temp dir");
883        gix::init(dir.path()).expect("init");
884        git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
885        std::fs::write(
886            dir.path().join(".git").join("HEAD"),
887            "not a ref or an object id\n",
888        )
889        .expect("corrupt HEAD");
890
891        let result = head_shape_at(dir.path());
892
893        assert!(
894            result.is_err(),
895            "a HEAD that will not parse must be an error, got {result:?}"
896        );
897    }
898
899    /// The defining behaviour behind the shared-handle probe path: two
900    /// `Repository` instances derived from the same `ThreadSafeRepository`, on two
901    /// different threads, each read `HEAD` correctly, proving the shared handle is
902    /// never the thing actually touched by a probe, only the source each task's
903    /// own private handle is derived from.
904    /// Pins the mechanism [`OBJECT_CACHE_BYTES`] depends on: the cache is configured at
905    /// open time, and gix re-applies it from the stored config on every handle derived
906    /// from the shared `ThreadSafeRepository`, so a probe running in a later generation
907    /// still gets one. A plain `gix::open` is checked alongside it, since the whole change
908    /// is the difference between the two and a test that only asserted the positive would
909    /// still pass if gix started setting a cache by default.
910    #[test]
911    fn every_derived_handle_carries_an_object_cache_and_a_plain_open_does_not() {
912        let dir = tempfile::tempdir().expect("temp dir");
913        init_repo_with_a_commit(dir.path());
914
915        let shared = open_thread_safe(dir.path()).expect("open");
916        assert!(
917            shared.to_thread_local().objects.has_object_cache(),
918            "the first handle derived from the shared repository must carry an object cache"
919        );
920        assert!(
921            shared.to_thread_local().objects.has_object_cache(),
922            "a second handle, standing in for a later generation's probe, must carry one too"
923        );
924
925        assert!(
926            !gix::open(dir.path())
927                .expect("plain open")
928                .objects
929                .has_object_cache(),
930            "gix still leaves the object cache off by default, which is what this change is"
931        );
932    }
933
934    #[test]
935    fn two_threads_each_derive_their_own_repository_from_one_shared_handle() {
936        let dir = tempfile::tempdir().expect("temp dir");
937        gix::init(dir.path()).expect("init");
938        git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
939
940        let shared = Arc::new(open_thread_safe(dir.path()).expect("open thread-safe repo"));
941
942        let readers: Vec<_> = (0..4)
943            .map(|_| {
944                let shared = Arc::clone(&shared);
945                std::thread::spawn(move || head_shape(&shared.to_thread_local()))
946            })
947            .collect();
948
949        for reader in readers {
950            let head = reader
951                .join()
952                .expect("reader thread panicked")
953                .expect("read HEAD");
954            assert!(matches!(head, Head::Branch { .. }));
955        }
956    }
957
958    #[test]
959    fn a_repository_with_no_operation_in_progress_reads_none() {
960        let dir = tempfile::tempdir().expect("temp dir");
961        gix::init(dir.path()).expect("init");
962        git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
963
964        let repo = open_thread_safe(dir.path()).expect("open repo");
965        assert_eq!(in_progress_operation(&repo.to_thread_local()), None);
966    }
967
968    /// The defining behaviour: a merge stopped on conflict must read as `Merge`,
969    /// proven against a real conflicted merge rather than a hand-written marker
970    /// file, so a change to git's own marker layout would show up here too.
971    #[test]
972    fn a_conflicted_merge_reads_as_an_in_progress_merge_operation() {
973        let dir = tempfile::tempdir().expect("temp dir");
974        gix::init(dir.path()).expect("init");
975        std::fs::write(dir.path().join("file.txt"), "base\n").expect("write file");
976        git(dir.path(), &["add", "file.txt"]);
977        git(dir.path(), &["commit", "-m", "base"]);
978        git(dir.path(), &["checkout", "-b", "feature"]);
979        std::fs::write(dir.path().join("file.txt"), "feature\n").expect("write file");
980        git(dir.path(), &["commit", "-am", "feature change"]);
981        git(dir.path(), &["checkout", "-"]);
982        std::fs::write(dir.path().join("file.txt"), "main\n").expect("write file");
983        git(dir.path(), &["commit", "-am", "main change"]);
984        // Expected to exit non-zero on conflict, so this cannot go through the
985        // helper, which asserts success. It still needs the helper's identity
986        // arguments, since a machine with no global identity refuses the merge
987        // outright and leaves no marker file behind.
988        let merge = std::process::Command::new("git")
989            .arg("-C")
990            .arg(dir.path())
991            .args(["-c", "user.email=test@example.com", "-c", "user.name=Test"])
992            .args(["merge", "feature"])
993            .output()
994            .expect("run git merge");
995
996        // The fixture is the marker file, so prove it exists before reading the
997        // repository: a merge that never started fails here, naming why, rather
998        // than as an opaque None further down.
999        assert!(
1000            dir.path().join(".git/MERGE_HEAD").exists(),
1001            "the merge left no MERGE_HEAD, so there is no in-progress operation to read. \
1002             git exited {:?}\nstdout: {}\nstderr: {}",
1003            merge.status.code(),
1004            String::from_utf8_lossy(&merge.stdout),
1005            String::from_utf8_lossy(&merge.stderr),
1006        );
1007
1008        let repo = open_thread_safe(dir.path()).expect("open repo");
1009
1010        assert_eq!(
1011            in_progress_operation(&repo.to_thread_local()),
1012            Some(InProgressOperation::Merge)
1013        );
1014    }
1015
1016    #[test]
1017    fn recent_commits_is_empty_on_an_unborn_head() {
1018        let dir = tempfile::tempdir().expect("temp dir");
1019        gix::init(dir.path()).expect("init");
1020
1021        let repo = open_thread_safe(dir.path()).expect("open repo");
1022
1023        assert_eq!(recent_commits(&repo.to_thread_local(), 5), Vec::new());
1024    }
1025
1026    #[test]
1027    fn recent_commits_reads_the_most_recent_first_with_its_message_summary() {
1028        let dir = tempfile::tempdir().expect("temp dir");
1029        gix::init(dir.path()).expect("init");
1030        git(
1031            dir.path(),
1032            &["commit", "--allow-empty", "-m", "first commit"],
1033        );
1034        git(
1035            dir.path(),
1036            &["commit", "--allow-empty", "-m", "second commit"],
1037        );
1038
1039        let repo = open_thread_safe(dir.path()).expect("open repo");
1040        let commits = recent_commits(&repo.to_thread_local(), 5);
1041
1042        assert_eq!(commits.len(), 2);
1043        assert_eq!(&*commits[0].summary, "second commit");
1044        assert_eq!(&*commits[1].summary, "first commit");
1045        assert_eq!(commits[0].short_id.len(), 7);
1046    }
1047
1048    #[test]
1049    fn recent_commits_is_capped_at_the_given_limit() {
1050        let dir = tempfile::tempdir().expect("temp dir");
1051        gix::init(dir.path()).expect("init");
1052        for n in 0..5 {
1053            git(
1054                dir.path(),
1055                &["commit", "--allow-empty", "-m", &format!("commit {n}")],
1056            );
1057        }
1058
1059        let repo = open_thread_safe(dir.path()).expect("open repo");
1060        let commits = recent_commits(&repo.to_thread_local(), 2);
1061
1062        assert_eq!(commits.len(), 2);
1063    }
1064
1065    #[test]
1066    fn every_variant_clones() {
1067        let open = ProbeError::Open(Arc::from("boom"));
1068        let read = ProbeError::Read(Arc::from("boom"));
1069        let submodules = ProbeError::Submodules(Arc::from("boom"));
1070        let ancestry = ProbeError::Ancestry(Arc::from("boom"));
1071
1072        assert_eq!(open.clone().to_string(), open.to_string());
1073        assert_eq!(read.clone().to_string(), read.to_string());
1074        assert_eq!(submodules.clone().to_string(), submodules.to_string());
1075        assert_eq!(ancestry.clone().to_string(), ancestry.to_string());
1076    }
1077
1078    fn init_repo_with_a_commit(path: &Path) {
1079        std::fs::create_dir_all(path).expect("create repo dir");
1080        gix::init(path).expect("init repo");
1081        git(path, &["commit", "--allow-empty", "-m", "first"]);
1082    }
1083
1084    #[test]
1085    fn an_ordinary_repository_resolves_as_a_repo_whose_common_dir_is_its_own_git_dir() {
1086        let dir = tempfile::tempdir().expect("temp dir");
1087        let root = dir.path().canonicalize().expect("canonicalize temp dir");
1088        init_repo_with_a_commit(&root);
1089
1090        let resolved = resolve_boundary(&root).expect("resolve boundary");
1091
1092        assert!(matches!(resolved.kind, Kind::Repo));
1093        assert_eq!(resolved.common_dir.as_ref(), root.join(".git"));
1094    }
1095
1096    /// The defining behaviour: a linked Worktree resolves to its own Kind, distinct
1097    /// from a Repo, and its common dir names the shared object store rather than
1098    /// its own private per-worktree admin directory, which is what proves the two
1099    /// are never confused for one another.
1100    #[test]
1101    fn a_linked_worktree_resolves_as_a_worktree_sharing_its_parents_common_dir() {
1102        let dir = tempfile::tempdir().expect("temp dir");
1103        let parent = dir.path().join("parent");
1104        init_repo_with_a_commit(&parent);
1105        let worktree = dir.path().join("worktree");
1106        git(
1107            &parent,
1108            &[
1109                "worktree",
1110                "add",
1111                "-b",
1112                "feature",
1113                worktree.to_str().expect("utf8 path"),
1114            ],
1115        );
1116
1117        let parent_resolved = resolve_boundary(&parent).expect("resolve parent");
1118        let worktree_resolved = resolve_boundary(&worktree).expect("resolve worktree");
1119
1120        assert!(matches!(worktree_resolved.kind, Kind::Worktree));
1121        assert!(matches!(parent_resolved.kind, Kind::Repo));
1122        assert_eq!(worktree_resolved.common_dir, parent_resolved.common_dir);
1123    }
1124
1125    /// [`linked_worktree_paths`] names the same Worktree [`linked_worktrees`] merely
1126    /// counts, opened from either side of the pair: the parent Repo and one of its own
1127    /// linked Worktrees share one `<common dir>/worktrees` register.
1128    #[test]
1129    fn linked_worktree_paths_names_the_worktree_linked_worktrees_counts() {
1130        let dir = tempfile::tempdir().expect("temp dir");
1131        let root = dir.path().canonicalize().expect("canonicalize temp dir");
1132        let parent = root.join("parent");
1133        init_repo_with_a_commit(&parent);
1134        let worktree = root.join("worktree");
1135        git(
1136            &parent,
1137            &[
1138                "worktree",
1139                "add",
1140                "-b",
1141                "feature",
1142                worktree.to_str().expect("utf8 path"),
1143            ],
1144        );
1145
1146        let repo = open_thread_safe(&parent).expect("open parent");
1147        let repo = repo.to_thread_local();
1148
1149        assert_eq!(linked_worktrees(&repo).expect("count"), 1);
1150        assert_eq!(linked_worktree_paths(&repo).expect("paths"), vec![worktree]);
1151    }
1152
1153    /// The one fact that tells a Worktree's own administrative entry apart from the
1154    /// shared object store: `git_dir()` names the former, `common_dir()` the latter, and
1155    /// only the former is what `git worktree remove` deletes for one Worktree alone.
1156    #[test]
1157    fn worktree_admin_dir_is_the_worktrees_own_git_dir_not_the_shared_common_dir() {
1158        let dir = tempfile::tempdir().expect("temp dir");
1159        let parent = dir.path().join("parent");
1160        init_repo_with_a_commit(&parent);
1161        let worktree = dir.path().join("worktree");
1162        git(
1163            &parent,
1164            &[
1165                "worktree",
1166                "add",
1167                "-b",
1168                "feature",
1169                worktree.to_str().expect("utf8 path"),
1170            ],
1171        );
1172
1173        let repo = open_thread_safe(&worktree).expect("open worktree");
1174        let repo = repo.to_thread_local();
1175
1176        let admin_dir = worktree_admin_dir(&repo)
1177            .canonicalize()
1178            .expect("canonicalize admin dir");
1179        let common_dir = repo
1180            .common_dir()
1181            .canonicalize()
1182            .expect("canonicalize common dir");
1183        assert_ne!(admin_dir, common_dir);
1184        assert!(
1185            admin_dir.starts_with(common_dir.join("worktrees")),
1186            "expected {admin_dir:?} under {:?}",
1187            common_dir.join("worktrees")
1188        );
1189    }
1190
1191    #[test]
1192    fn a_repo_with_no_gitmodules_resolves_to_no_submodules() {
1193        let dir = tempfile::tempdir().expect("temp dir");
1194        init_repo_with_a_commit(dir.path());
1195
1196        let resolved = resolve_boundary(dir.path()).expect("resolve boundary");
1197
1198        assert_eq!(resolved.submodules.expect("no read failure"), Vec::new());
1199    }
1200
1201    /// Hand-writes a `.gitmodules` file rather than running `git submodule add`
1202    /// against a real remote, so the fixture stays hermetic and fast; discovery
1203    /// only ever reads this file, never the module it names.
1204    fn write_gitmodules(repo: &Path, entries: &[(&str, &str)]) {
1205        let mut contents = String::new();
1206        for (name, path) in entries {
1207            contents.push_str(&format!(
1208                "[submodule \"{name}\"]\n\tpath = {path}\n\turl = https://example.com/{name}.git\n"
1209            ));
1210        }
1211        std::fs::write(repo.join(".gitmodules"), contents).expect("write .gitmodules");
1212    }
1213
1214    #[test]
1215    fn a_gitmodules_entry_is_read_with_its_name_and_relative_path() {
1216        let dir = tempfile::tempdir().expect("temp dir");
1217        init_repo_with_a_commit(dir.path());
1218        write_gitmodules(dir.path(), &[("lib", "vendor/lib")]);
1219
1220        let resolved = resolve_boundary(dir.path()).expect("resolve boundary");
1221        let submodules = resolved.submodules.expect("no read failure");
1222
1223        assert_eq!(submodules.len(), 1);
1224        assert_eq!(&*submodules[0].name, "lib");
1225        assert_eq!(submodules[0].relative_path, Path::new("vendor/lib"));
1226    }
1227
1228    #[test]
1229    fn a_gitmodules_file_that_will_not_parse_is_reported_as_a_submodules_failure() {
1230        let dir = tempfile::tempdir().expect("temp dir");
1231        init_repo_with_a_commit(dir.path());
1232        // An unterminated section header: not valid git-config syntax.
1233        std::fs::write(
1234            dir.path().join(".gitmodules"),
1235            "[submodule \"lib\"\n\tpath = lib\n",
1236        )
1237        .expect("write malformed .gitmodules");
1238
1239        let resolved = resolve_boundary(dir.path()).expect("resolve boundary");
1240
1241        assert!(matches!(
1242            resolved.submodules,
1243            Err(ProbeError::Submodules(_))
1244        ));
1245    }
1246
1247    /// gix's own quirk, recorded in discovery.md: a `.gitmodules` that is itself a
1248    /// symlink reads as absent rather than being followed and parsed.
1249    #[test]
1250    fn a_symlinked_gitmodules_file_is_treated_as_absent() {
1251        let dir = tempfile::tempdir().expect("temp dir");
1252        init_repo_with_a_commit(dir.path());
1253        let real_file = dir.path().join("real-gitmodules");
1254        std::fs::write(
1255            &real_file,
1256            "[submodule \"lib\"]\n\tpath = lib\n\turl = https://example.com/lib.git\n",
1257        )
1258        .expect("write real gitmodules contents");
1259        std::os::unix::fs::symlink(&real_file, dir.path().join(".gitmodules"))
1260            .expect("create symlink");
1261
1262        let resolved = resolve_boundary(dir.path()).expect("resolve boundary");
1263
1264        assert_eq!(resolved.submodules.expect("no read failure"), Vec::new());
1265    }
1266
1267    // --- has_any_remote, ahead_behind and resolve_sync: Phase B's comparison ---
1268
1269    /// Wires `branch_name` up to track `refs/remotes/origin/<branch_name>` at
1270    /// `upstream_sha`, adding `origin` first if this repo has no remote yet. Mirrors
1271    /// `core.rs`'s own `patch_equivalence_is_memoised_once_per_common_dir_per_generation`
1272    /// fixture, which sets up an upstream the same way against a real disposable repo.
1273    fn configure_upstream(path: &Path, branch_name: &str, upstream_sha: &str) {
1274        let repo = open_thread_safe(path).expect("open repo").to_thread_local();
1275        if !has_any_remote(&repo) {
1276            git(
1277                path,
1278                &[
1279                    "remote",
1280                    "add",
1281                    "origin",
1282                    "https://example.invalid/repo.git",
1283                ],
1284            );
1285        }
1286        git(
1287            path,
1288            &["config", &format!("branch.{branch_name}.remote"), "origin"],
1289        );
1290        git(
1291            path,
1292            &[
1293                "config",
1294                &format!("branch.{branch_name}.merge"),
1295                &format!("refs/heads/{branch_name}"),
1296            ],
1297        );
1298        git(
1299            path,
1300            &[
1301                "update-ref",
1302                &format!("refs/remotes/origin/{branch_name}"),
1303                upstream_sha,
1304            ],
1305        );
1306    }
1307
1308    #[test]
1309    fn has_any_remote_is_false_until_one_is_added() {
1310        let dir = tempfile::tempdir().expect("temp dir");
1311        init_repo_with_a_commit(dir.path());
1312        let repo = open_thread_safe(dir.path())
1313            .expect("open")
1314            .to_thread_local();
1315        assert!(!has_any_remote(&repo));
1316
1317        git(
1318            dir.path(),
1319            &[
1320                "remote",
1321                "add",
1322                "origin",
1323                "https://example.invalid/repo.git",
1324            ],
1325        );
1326        let repo = open_thread_safe(dir.path())
1327            .expect("open")
1328            .to_thread_local();
1329        assert!(has_any_remote(&repo));
1330    }
1331
1332    /// The arithmetic `resolve_sync` leans on: two rev-walks, each hidden behind the
1333    /// other's tip. `main` gains two commits of its own after the fork point while
1334    /// `feature` (built off the same fork point) gains one of its own, so `main` is
1335    /// 2 ahead of `feature` and `feature` is 1 ahead of (2 behind, from `main`'s own
1336    /// point of view) `main`; asymmetric counts on both sides are what catches a
1337    /// swapped `tip`/`hidden` argument, which a symmetric fixture could not.
1338    #[test]
1339    fn ahead_behind_counts_commits_unique_to_each_side_not_the_total_on_either() {
1340        let dir = tempfile::tempdir().expect("temp dir");
1341        init_repo_with_a_commit(dir.path());
1342        let fork_sha = head_sha(dir.path());
1343        git(dir.path(), &["checkout", "-b", "feature"]);
1344        std::fs::write(dir.path().join("feature.txt"), "one\n").expect("write file");
1345        git(dir.path(), &["add", "."]);
1346        git(dir.path(), &["commit", "-m", "feature work"]);
1347        let feature_sha = head_sha(dir.path());
1348        git(dir.path(), &["checkout", "main"]);
1349        for name in ["a", "b"] {
1350            std::fs::write(dir.path().join(format!("{name}.txt")), "content\n")
1351                .expect("write file");
1352            git(dir.path(), &["add", "."]);
1353            git(dir.path(), &["commit", "-m", &format!("main work {name}")]);
1354        }
1355        let main_sha = head_sha(dir.path());
1356        let repo = open_thread_safe(dir.path())
1357            .expect("open")
1358            .to_thread_local();
1359        let fork = gix::ObjectId::from_hex(fork_sha.as_bytes()).expect("parse sha");
1360        let feature = gix::ObjectId::from_hex(feature_sha.as_bytes()).expect("parse sha");
1361        let main = gix::ObjectId::from_hex(main_sha.as_bytes()).expect("parse sha");
1362
1363        let against_fork = ahead_behind(&repo, main, fork).expect("ahead/behind against fork");
1364        assert_eq!(
1365            against_fork,
1366            AheadBehind {
1367                ahead: 2,
1368                behind: 0
1369            }
1370        );
1371
1372        let against_feature =
1373            ahead_behind(&repo, main, feature).expect("ahead/behind against feature");
1374        assert_eq!(
1375            against_feature,
1376            AheadBehind {
1377                ahead: 2,
1378                behind: 1
1379            },
1380            "main's own two commits are ahead, feature's own one commit is behind"
1381        );
1382    }
1383
1384    #[test]
1385    fn ahead_behind_of_a_branch_against_itself_is_zero_and_zero() {
1386        let dir = tempfile::tempdir().expect("temp dir");
1387        init_repo_with_a_commit(dir.path());
1388        let sha = head_sha(dir.path());
1389        let repo = open_thread_safe(dir.path())
1390            .expect("open")
1391            .to_thread_local();
1392        let commit = gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha");
1393
1394        let counts = ahead_behind(&repo, commit, commit).expect("ahead/behind reflexive");
1395
1396        assert_eq!(
1397            counts,
1398            AheadBehind {
1399                ahead: 0,
1400                behind: 0
1401            }
1402        );
1403    }
1404
1405    #[test]
1406    fn resolve_sync_settles_no_remote_even_though_the_branch_has_a_configured_upstream() {
1407        let dir = tempfile::tempdir().expect("temp dir");
1408        init_repo_with_a_commit(dir.path());
1409        let sha = head_sha(dir.path());
1410        // `branch.<name>.remote`/`.merge` and the tracking ref itself are set by hand,
1411        // with no `[remote "origin"]` section ever created: proves `has_any_remote`'s
1412        // check runs, and wins, before the branch's own tracking config is even read.
1413        git(dir.path(), &["config", "branch.main.remote", "origin"]);
1414        git(
1415            dir.path(),
1416            &["config", "branch.main.merge", "refs/heads/main"],
1417        );
1418        git(
1419            dir.path(),
1420            &["update-ref", "refs/remotes/origin/main", &sha],
1421        );
1422        let repo = open_thread_safe(dir.path())
1423            .expect("open")
1424            .to_thread_local();
1425        let head = Head::Branch {
1426            name: Arc::from("main"),
1427            commit: gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha"),
1428        };
1429
1430        let sync = resolve_sync(&repo, Some(&head)).expect("resolve sync");
1431
1432        assert_eq!(sync, SyncState::NoRemote);
1433    }
1434
1435    #[test]
1436    fn resolve_sync_settles_no_upstream_for_a_branch_with_no_tracking_configured() {
1437        let dir = tempfile::tempdir().expect("temp dir");
1438        init_repo_with_a_commit(dir.path());
1439        git(
1440            dir.path(),
1441            &[
1442                "remote",
1443                "add",
1444                "origin",
1445                "https://example.invalid/repo.git",
1446            ],
1447        );
1448        let sha = head_sha(dir.path());
1449        let repo = open_thread_safe(dir.path())
1450            .expect("open")
1451            .to_thread_local();
1452        let head = Head::Branch {
1453            name: Arc::from("main"),
1454            commit: gix::ObjectId::from_hex(sha.as_bytes()).expect("parse sha"),
1455        };
1456
1457        let sync = resolve_sync(&repo, Some(&head)).expect("resolve sync");
1458
1459        assert_eq!(sync, SyncState::NoUpstream);
1460    }
1461
1462    /// No branch at all (a detached or unborn HEAD) settles the same way a branch with no
1463    /// upstream does, on a repo that does have a remote: `resolve_sync` never invents a
1464    /// name to look an upstream up under.
1465    #[test]
1466    fn resolve_sync_settles_no_upstream_when_head_carries_no_branch() {
1467        let dir = tempfile::tempdir().expect("temp dir");
1468        init_repo_with_a_commit(dir.path());
1469        git(
1470            dir.path(),
1471            &[
1472                "remote",
1473                "add",
1474                "origin",
1475                "https://example.invalid/repo.git",
1476            ],
1477        );
1478        let repo = open_thread_safe(dir.path())
1479            .expect("open")
1480            .to_thread_local();
1481
1482        let sync = resolve_sync(&repo, None).expect("resolve sync");
1483
1484        assert_eq!(sync, SyncState::NoUpstream);
1485    }
1486
1487    #[test]
1488    fn resolve_sync_computes_tracking_counts_against_a_live_upstream() {
1489        let dir = tempfile::tempdir().expect("temp dir");
1490        init_repo_with_a_commit(dir.path());
1491        let upstream_sha = head_sha(dir.path());
1492        configure_upstream(dir.path(), "main", &upstream_sha);
1493        git(dir.path(), &["commit", "--allow-empty", "-m", "local work"]);
1494        let tip_sha = head_sha(dir.path());
1495        let repo = open_thread_safe(dir.path())
1496            .expect("open")
1497            .to_thread_local();
1498        let head = Head::Branch {
1499            name: Arc::from("main"),
1500            commit: gix::ObjectId::from_hex(tip_sha.as_bytes()).expect("parse sha"),
1501        };
1502
1503        let sync = resolve_sync(&repo, Some(&head)).expect("resolve sync");
1504
1505        assert_eq!(
1506            sync,
1507            SyncState::Tracking(AheadBehind {
1508                ahead: 1,
1509                behind: 0
1510            })
1511        );
1512    }
1513
1514    /// Criterion 1: the status phase produces typed counts, not a single total. Every count
1515    /// is a different number (1 modified, 2 deleted, 3 untracked) precisely so a test that
1516    /// read one count into another's slot would fail rather than pass by coincidence.
1517    #[test]
1518    fn dirty_counts_reports_distinct_typed_counts_for_modified_untracked_and_deleted_paths() {
1519        let dir = tempfile::tempdir().expect("temp dir");
1520        gix::init(dir.path()).expect("init repo");
1521        std::fs::write(dir.path().join("tracked-modified.txt"), "original\n")
1522            .expect("write tracked file");
1523        std::fs::write(dir.path().join("tracked-deleted-1.txt"), "bye\n")
1524            .expect("write tracked file");
1525        std::fs::write(dir.path().join("tracked-deleted-2.txt"), "bye\n")
1526            .expect("write tracked file");
1527        git(dir.path(), &["add", "."]);
1528        git(dir.path(), &["commit", "-m", "first"]);
1529
1530        // One modification: content changed against the index.
1531        std::fs::write(dir.path().join("tracked-modified.txt"), "changed\n")
1532            .expect("modify tracked file");
1533        // Two deletions: removed from the working tree, still in the index.
1534        std::fs::remove_file(dir.path().join("tracked-deleted-1.txt"))
1535            .expect("delete tracked file");
1536        std::fs::remove_file(dir.path().join("tracked-deleted-2.txt"))
1537            .expect("delete tracked file");
1538        // Three untracked files: never added.
1539        for name in ["new-1.txt", "new-2.txt", "new-3.txt"] {
1540            std::fs::write(dir.path().join(name), "x").expect("write untracked file");
1541        }
1542
1543        let repo = open_thread_safe(dir.path())
1544            .expect("open")
1545            .to_thread_local();
1546        let counts =
1547            dirty_counts(&repo, Arc::new(AtomicBool::new(false))).expect("compute dirty counts");
1548
1549        assert_eq!(
1550            counts,
1551            DirtyCounts {
1552                modified: 1,
1553                untracked: 3,
1554                deleted: 2,
1555            }
1556        );
1557    }
1558
1559    /// Criterion 1's other claim, the boolean check's own rejected trade-off: a clean
1560    /// working tree settles every count to zero, the same "prove clean" case
1561    /// [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)
1562    /// measured as costing the same as counting.
1563    #[test]
1564    fn dirty_counts_reports_a_clean_working_tree_as_all_zero() {
1565        let dir = tempfile::tempdir().expect("temp dir");
1566        init_repo_with_a_commit(dir.path());
1567        let repo = open_thread_safe(dir.path())
1568            .expect("open")
1569            .to_thread_local();
1570
1571        let counts =
1572            dirty_counts(&repo, Arc::new(AtomicBool::new(false))).expect("compute dirty counts");
1573
1574        assert_eq!(counts, DirtyCounts::default());
1575    }
1576
1577    /// gix's own contract for `should_interrupt_owned`: it takes the `Arc` by value and holds
1578    /// it in the platform it returns, rather than merely borrowing it, so the platform's strong
1579    /// count rises by exactly one for as long as it lives. This is half of the proof that
1580    /// `dirty_counts` threads `cancel` into gix; the other half, that `dirty_counts` actually
1581    /// calls `should_interrupt_owned` with its own `cancel` parameter, is
1582    /// `dirty_counts_passes_its_own_cancel_flag_to_should_interrupt_owned` in
1583    /// `crates/repon/src/test_support.rs`. Neither test alone proves the flag reaches gix from
1584    /// a real cancellation; asserting on the outcome of a walk instead would race it, which is
1585    /// what this test replaced.
1586    #[test]
1587    fn should_interrupt_owned_holds_its_own_clone_of_the_cancel_flag() {
1588        let dir = tempfile::tempdir().expect("temp dir");
1589        init_repo_with_a_commit(dir.path());
1590        let repo = open_thread_safe(dir.path())
1591            .expect("open")
1592            .to_thread_local();
1593        let cancel = Arc::new(AtomicBool::new(false));
1594        let before = Arc::strong_count(&cancel);
1595
1596        let platform = repo
1597            .status(gix::progress::Discard)
1598            .expect("status platform")
1599            .should_interrupt_owned(Arc::clone(&cancel));
1600
1601        assert_eq!(
1602            Arc::strong_count(&cancel),
1603            before + 1,
1604            "should_interrupt_owned must hold its own clone of the cancel flag for the \
1605             platform's lifetime, not merely borrow it"
1606        );
1607        drop(platform);
1608        assert_eq!(
1609            Arc::strong_count(&cancel),
1610            before,
1611            "dropping the platform must release its clone rather than leaking it"
1612        );
1613    }
1614
1615    // --- the `delete` confirm gate's unpushed count (docs/spec/repo-management.md) ---
1616
1617    /// A repository with one commit and nothing under `refs/remotes/`, built in a temp
1618    /// directory of this test's own making: no path from config, no environment variable and
1619    /// no working directory reaches here.
1620    fn repository_with_one_commit() -> tempfile::TempDir {
1621        let dir = tempfile::tempdir().expect("temp dir");
1622        gix::init(dir.path()).expect("init");
1623        git(dir.path(), &["commit", "--allow-empty", "-m", "first"]);
1624        dir
1625    }
1626
1627    fn opened(path: &Path) -> gix::Repository {
1628        open_thread_safe(path).expect("open").to_thread_local()
1629    }
1630
1631    /// The case ADR 0028 names as the one that actually loses work: a Repo whose commits sit
1632    /// on no remote at all. Every commit is unpushed, on the one branch carrying them.
1633    #[test]
1634    fn a_repository_with_no_remote_ref_at_all_has_every_commit_unpushed() {
1635        let dir = repository_with_one_commit();
1636
1637        let (commits, branches) = unpushed(&opened(dir.path())).expect("count unpushed");
1638
1639        assert_eq!((commits, branches), (1, 1));
1640    }
1641
1642    /// A remote-tracking ref carrying the same commit leaves nothing unpushed, which is the
1643    /// "listed plainly" half of the gate.
1644    #[test]
1645    fn a_commit_a_remote_tracking_ref_already_carries_is_not_unpushed() {
1646        let dir = repository_with_one_commit();
1647        let sha = head_sha(dir.path());
1648        git(
1649            dir.path(),
1650            &["update-ref", "refs/remotes/origin/main", &sha],
1651        );
1652
1653        let (commits, branches) = unpushed(&opened(dir.path())).expect("count unpushed");
1654
1655        assert_eq!((commits, branches), (0, 0));
1656    }
1657
1658    /// The count is per commit and per branch, and a commit reachable from two local branches
1659    /// is one unpushed commit on two branches rather than two commits.
1660    #[test]
1661    fn unpushed_counts_commits_once_and_names_every_branch_carrying_one() {
1662        let dir = repository_with_one_commit();
1663        let sha = head_sha(dir.path());
1664        git(
1665            dir.path(),
1666            &["update-ref", "refs/remotes/origin/main", &sha],
1667        );
1668        git(dir.path(), &["commit", "--allow-empty", "-m", "second"]);
1669        git(dir.path(), &["branch", "sidecar"]);
1670        git(dir.path(), &["checkout", "sidecar"]);
1671        git(dir.path(), &["commit", "--allow-empty", "-m", "third"]);
1672
1673        let (commits, branches) = unpushed(&opened(dir.path())).expect("count unpushed");
1674
1675        assert_eq!(
1676            (commits, branches),
1677            (2, 2),
1678            "the commit both branches carry counts once, not once per branch, and both \
1679             branches carrying one are named"
1680        );
1681    }
1682
1683    /// An unborn HEAD has no local branch to walk, and answering zero there is a fact rather
1684    /// than a fallback: there is nothing committed to lose.
1685    #[test]
1686    fn an_unborn_repository_has_nothing_unpushed() {
1687        let dir = tempfile::tempdir().expect("temp dir");
1688        gix::init(dir.path()).expect("init");
1689
1690        let (commits, branches) = unpushed(&opened(dir.path())).expect("count unpushed");
1691
1692        assert_eq!((commits, branches), (0, 0));
1693    }
1694
1695    // --- `delete`'s phase 1: ignored directories (docs/spec/repo-management.md) ---
1696
1697    /// The defining behaviour: a `node_modules` full of files collapses to one returned
1698    /// entry, the directory itself, rather than one per file inside it.
1699    #[test]
1700    fn ignored_directories_for_deletion_collapses_an_ignored_tree_to_its_own_root() {
1701        let dir = tempfile::tempdir().expect("temp dir");
1702        let root = dir.path().canonicalize().expect("canonicalize temp dir");
1703        init_repo_with_a_commit(&root);
1704        std::fs::write(root.join(".gitignore"), "node_modules/\n").expect("write .gitignore");
1705        std::fs::create_dir_all(root.join("node_modules").join("a-package"))
1706            .expect("create node_modules");
1707        std::fs::write(
1708            root.join("node_modules").join("a-package").join("index.js"),
1709            "module.exports = {};\n",
1710        )
1711        .expect("write nested file");
1712        git(&root, &["add", ".gitignore"]);
1713        git(&root, &["commit", "-m", "ignore node_modules"]);
1714
1715        let ignored = ignored_directories_for_deletion(&opened(&root)).expect("enumerate ignored");
1716
1717        assert_eq!(ignored, vec![root.join("node_modules")]);
1718    }
1719
1720    /// Nothing tracked or merely untracked is ignored, so a working tree with neither
1721    /// reports no ignored directories at all.
1722    #[test]
1723    fn ignored_directories_for_deletion_is_empty_with_no_gitignore() {
1724        let dir = tempfile::tempdir().expect("temp dir");
1725        let root = dir.path().canonicalize().expect("canonicalize temp dir");
1726        init_repo_with_a_commit(&root);
1727        std::fs::write(root.join("tracked.txt"), "tracked\n").expect("write tracked file");
1728        git(&root, &["add", "tracked.txt"]);
1729        git(&root, &["commit", "-m", "add tracked file"]);
1730        std::fs::write(root.join("untracked.txt"), "untracked\n").expect("write untracked file");
1731
1732        let ignored = ignored_directories_for_deletion(&opened(&root)).expect("enumerate ignored");
1733
1734        assert_eq!(ignored, Vec::<PathBuf>::new());
1735    }
1736
1737    /// A bare repository has no working tree to walk: this is "nothing to enumerate", never
1738    /// the error `dirwalk` itself would raise for a missing workdir.
1739    #[test]
1740    fn ignored_directories_for_deletion_on_a_bare_repository_is_empty() {
1741        let dir = tempfile::tempdir().expect("temp dir");
1742        gix::init_bare(dir.path()).expect("init bare");
1743
1744        let ignored =
1745            ignored_directories_for_deletion(&opened(dir.path())).expect("enumerate ignored");
1746
1747        assert_eq!(ignored, Vec::<PathBuf>::new());
1748    }
1749}