Skip to main content

linesmith_core/data_context/
git.rs

1//! Git repository inspection state via `gix`.
2//!
3//! Canonical definition: `docs/specs/git-segments.md` §GitContext type.
4//!
5//! Two tiers of laziness. [`DataContext::git`](super::DataContext::git)
6//! decides whether to open the repo at all. Once opened, [`GitContext`]
7//! exposes lazy [`dirty`](GitContext::dirty) and
8//! [`upstream`](GitContext::upstream) accessors so segments that don't
9//! read those fields skip the scan entirely.
10
11use std::cell::OnceCell;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use super::error::GitError;
16
17/// Which flavor of repository `gix::discover` found.
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum RepoKind {
21    /// A regular checkout with a `.git/` directory.
22    Main,
23    /// A linked worktree (`.git` is a file with `gitdir: ...`). `name`
24    /// is the per-worktree directory basename (`.git/worktrees/<name>/`).
25    LinkedWorktree { name: String },
26    /// A bare repository. `git_branch` hides on this kind (no working
27    /// tree means no dirty state).
28    Bare,
29    /// A submodule checkout. Has a working tree and HEAD like
30    /// `Main`, but carried as a distinct variant so segments that
31    /// want to style submodules differently don't re-classify.
32    Submodule,
33}
34
35/// Resolved HEAD state.
36#[derive(Debug, Clone, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum Head {
39    /// HEAD → `refs/heads/<name>` with at least one commit. The
40    /// `String` is the short name (prefix stripped).
41    Branch(String),
42    /// Detached HEAD at a specific object.
43    Detached(gix::ObjectId),
44    /// Fresh `git init` with no commits. `symbolic_ref` is the short
45    /// name HEAD points at (whatever `init.defaultBranch` resolves to).
46    Unborn { symbolic_ref: String },
47    /// HEAD points at a ref outside `refs/heads/` (e.g. a remote-
48    /// tracking ref or a tag). `full_name` is the unstripped refname.
49    OtherRef { full_name: String },
50}
51
52impl Head {
53    /// Short plugin-facing tag used in the rhai ctx mirror.
54    #[must_use]
55    pub fn kind_str(&self) -> &'static str {
56        match self {
57            Self::Branch(_) => "branch",
58            Self::Detached(_) => "detached",
59            Self::Unborn { .. } => "unborn",
60            Self::OtherRef { .. } => "other_ref",
61        }
62    }
63}
64
65/// Dirty-state result.
66///
67/// - `Clean` — no tracked modifications and no untracked files.
68/// - `Dirty(None)` — indicator mode: scan short-circuited on the
69///   first dirty entry, so counts were not collected.
70/// - `Dirty(Some(counts))` — full-scan counts mode.
71///
72/// The two `Dirty` forms are distinct plugin-facing states so
73/// counts-mode renderers can tell "counts not yet computed" apart
74/// from "zero of this category."
75#[derive(Debug, Clone, Default, PartialEq, Eq)]
76#[non_exhaustive]
77pub enum DirtyState {
78    #[default]
79    Clean,
80    Dirty(Option<DirtyCounts>),
81}
82
83impl DirtyState {
84    /// True iff the working tree has any modification or untracked file.
85    #[must_use]
86    pub fn is_dirty(&self) -> bool {
87        matches!(self, Self::Dirty(_))
88    }
89}
90
91/// Per-category dirty counts. Populated only in counts mode;
92/// indicator-mode scans leave this absent.
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94#[non_exhaustive]
95pub struct DirtyCounts {
96    pub staged: u32,
97    pub unstaged: u32,
98    pub untracked: u32,
99}
100
101/// Upstream-tracking branch comparison.
102#[derive(Debug, Clone, PartialEq, Eq)]
103#[non_exhaustive]
104pub struct UpstreamState {
105    pub ahead: u32,
106    pub behind: u32,
107    pub upstream_branch: String,
108}
109
110/// Git state shared across all `git_*` segments for the duration of
111/// one render invocation. Populated once by
112/// [`resolve_repo`] and held behind an
113/// [`Arc`](std::sync::Arc) in [`DataContext`](super::DataContext).
114#[non_exhaustive]
115pub struct GitContext {
116    /// Which repo flavor was discovered.
117    pub repo_kind: RepoKind,
118    /// Absolute path to the repository directory (the `.git` dir for
119    /// main, the `.git/worktrees/<name>/` dir for linked worktrees,
120    /// the repo path itself for bare).
121    pub repo_path: PathBuf,
122    /// Resolved HEAD.
123    pub head: Head,
124
125    dirty: OnceCell<Arc<DirtyState>>,
126    upstream: OnceCell<Arc<Option<UpstreamState>>>,
127    /// Kept for lazy dirty / upstream resolution. `gix::Repository`
128    /// is `Send` (with the `parallel` feature) but not `Sync`; the
129    /// render path is single-threaded, so `OnceCell` suffices.
130    repo: Option<gix::Repository>,
131}
132
133impl std::fmt::Debug for GitContext {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_struct("GitContext")
136            .field("repo_kind", &self.repo_kind)
137            .field("repo_path", &self.repo_path)
138            .field("head", &self.head)
139            .field("dirty", &self.dirty.get().map(|arc| &**arc))
140            .field("upstream", &self.upstream.get().map(|arc| &**arc))
141            .finish_non_exhaustive()
142    }
143}
144
145impl GitContext {
146    /// Construct a [`GitContext`] from pre-resolved fields without
147    /// opening a repo. Lazy `dirty` / `upstream` accessors then
148    /// return their defaults (empty dirty state, no upstream)
149    /// because no `gix::Repository` is held. Pair with
150    /// [`DataContext::preseed_git`](super::DataContext::preseed_git).
151    #[must_use]
152    pub fn new(repo_kind: RepoKind, repo_path: PathBuf, head: Head) -> Self {
153        Self {
154            repo_kind,
155            repo_path,
156            head,
157            dirty: OnceCell::new(),
158            upstream: OnceCell::new(),
159            repo: None,
160        }
161    }
162
163    /// Dirty state, scanned lazily on first access. Returns
164    /// [`DirtyState::Clean`] when no repo handle is held.
165    ///
166    /// The scan covers untracked files and tracked modifications.
167    /// HEAD↔index (staged-only) changes are not detected because
168    /// gix 0.67 doesn't expose that comparison.
169    #[must_use]
170    pub fn dirty(&self) -> Arc<DirtyState> {
171        self.dirty
172            .get_or_init(|| match &self.repo {
173                Some(repo) => Arc::new(compute_dirty(repo).unwrap_or_else(|err| {
174                    // Silent false-clean would mask real gix failures
175                    // (e.g. index corruption); route through the
176                    // logger so `LINESMITH_LOG=off` can suppress it.
177                    crate::lsm_warn!("git dirty scan failed: {err}");
178                    DirtyState::Clean
179                })),
180                None => Arc::new(DirtyState::Clean),
181            })
182            .clone()
183    }
184
185    /// Pre-populate the `upstream` OnceCell with an explicit value,
186    /// bypassing the real walker. Returns `Err` via
187    /// [`OnceCell::set`]'s semantics when the cell was already
188    /// populated.
189    pub fn preseed_upstream(
190        &self,
191        value: Option<UpstreamState>,
192    ) -> Result<(), Arc<Option<UpstreamState>>> {
193        self.upstream.set(Arc::new(value))
194    }
195
196    /// Pre-populate the `dirty` OnceCell with an explicit value,
197    /// bypassing the real scan. Same `OnceCell::set` semantics as
198    /// [`Self::preseed_upstream`].
199    pub fn preseed_dirty_state(&self, value: DirtyState) -> Result<(), Arc<DirtyState>> {
200        self.dirty.set(Arc::new(value))
201    }
202
203    /// Upstream-tracking state, scanned lazily on first access.
204    ///
205    /// Returns `Arc<None>` in five distinct cases:
206    /// 1. HEAD is detached / unborn / an `OtherRef`.
207    /// 2. The branch has no tracking upstream configured.
208    /// 3. The configured tracking ref has no local object (never
209    ///    fetched, or remote pruned).
210    /// 4. The repo is shallow — ancestor walks truncate at the
211    ///    shallow frontier and would silently undercount.
212    /// 5. HEAD and upstream share no merge base (unrelated histories)
213    ///    OR `gix` failed partway through (corrupt index, cache open
214    ///    failure, ...). In the failure case the cause is written to
215    ///    stderr with the `linesmith:` prefix on the first read.
216    ///
217    /// Cases 1-4 render identically to ahead/behind segments (no
218    /// upstream). Case 5 deliberately fuses "walker failed" into "no
219    /// upstream" — distinguishing them in the plugin mirror requires
220    /// a structured variant (follow-up).
221    #[must_use]
222    pub fn upstream(&self) -> Arc<Option<UpstreamState>> {
223        self.upstream
224            .get_or_init(|| match &self.repo {
225                Some(repo) => Arc::new(compute_upstream(repo, &self.head).unwrap_or_else(|err| {
226                    crate::lsm_warn!("git ahead/behind scan failed: {err}");
227                    None
228                })),
229                None => Arc::new(None),
230            })
231            .clone()
232    }
233}
234
235/// Walk up from `cwd` looking for a repository. Returns `Ok(None)`
236/// only for the legitimate "no repo here" cases (no `.git` found
237/// walking up). Permission errors, trust rejections (`safe.directory`),
238/// ceiling-dir misconfig, and path-input errors surface as
239/// [`GitError::CorruptRepo`] so they reach the user instead of
240/// silently hiding the segment.
241pub fn resolve_repo(cwd: &Path) -> Result<Option<GitContext>, GitError> {
242    let repo = match gix::discover(cwd) {
243        Ok(r) => r,
244        Err(gix::discover::Error::Discover(inner)) => {
245            use gix::discover::upwards::Error as U;
246            match inner {
247                U::NoGitRepository { .. }
248                | U::NoGitRepositoryWithinCeiling { .. }
249                | U::NoGitRepositoryWithinFs { .. } => return Ok(None),
250                other => {
251                    return Err(GitError::CorruptRepo {
252                        path: cwd.to_path_buf(),
253                        message: other.to_string(),
254                    });
255                }
256            }
257        }
258        Err(e) => {
259            return Err(GitError::CorruptRepo {
260                path: cwd.to_path_buf(),
261                message: e.to_string(),
262            });
263        }
264    };
265
266    let repo_kind = classify_kind(&repo);
267    let repo_path = repo.git_dir().to_path_buf();
268    let head = resolve_head(&repo).map_err(|e| GitError::WalkFailed {
269        path: repo_path.clone(),
270        message: e,
271    })?;
272
273    Ok(Some(GitContext {
274        repo_kind,
275        repo_path,
276        head,
277        dirty: OnceCell::new(),
278        upstream: OnceCell::new(),
279        repo: Some(repo),
280    }))
281}
282
283/// Indicator-mode dirty scan: short-circuits on the first status
284/// entry. Covers untracked + worktree-vs-index (unstaged). Misses
285/// HEAD↔index (staged-only) per gix 0.67's own TODO on `is_dirty`.
286fn compute_dirty(repo: &gix::Repository) -> Result<DirtyState, Box<dyn std::error::Error>> {
287    use gix::status::UntrackedFiles;
288
289    let platform = repo
290        .status(gix::progress::Discard)?
291        .untracked_files(UntrackedFiles::Collapsed)
292        .index_worktree_rewrites(None);
293    for item in platform.into_index_worktree_iter(Vec::new())? {
294        if item.is_ok() {
295            return Ok(DirtyState::Dirty(None));
296        }
297    }
298    Ok(DirtyState::Clean)
299}
300
301/// Resolve the tracking branch for `head` and count its ahead/behind
302/// commits relative to HEAD. Returns `Ok(None)` for:
303/// - HEAD not on a local branch
304/// - no upstream configured on the branch
305/// - tracking ref configured but not present locally (never fetched)
306/// - shallow clones, where ancestor walks are truncated at the
307///   shallow frontier and counts would be wrong
308/// - unrelated histories (HEAD and upstream share no merge base)
309fn compute_upstream(
310    repo: &gix::Repository,
311    head: &Head,
312) -> Result<Option<UpstreamState>, Box<dyn std::error::Error>> {
313    let Head::Branch(_) = head else {
314        return Ok(None);
315    };
316
317    if repo.is_shallow() {
318        return Ok(None);
319    }
320
321    let head_ref = match repo.head_ref()? {
322        Some(r) => r,
323        None => return Ok(None),
324    };
325    let upstream_ref_name = match head_ref.remote_tracking_ref_name(gix::remote::Direction::Fetch) {
326        Some(Ok(name)) => name.into_owned(),
327        Some(Err(e)) => return Err(Box::new(e)),
328        None => return Ok(None),
329    };
330
331    let mut upstream_ref = match repo.try_find_reference(upstream_ref_name.as_ref())? {
332        Some(r) => r,
333        None => return Ok(None),
334    };
335    let upstream_oid = upstream_ref.peel_to_id()?.detach();
336    let head_oid = head_ref.id().detach();
337
338    // Explicit merge_base + manual exclusion avoids gix's
339    // `with_pruned`: that path's `ByCommitTimeCutoff` sort collides
340    // when two commits share a committer-date second, which breaks
341    // ahead/behind counts non-deterministically.
342    let (ahead, behind) = match repo.merge_base(head_oid, upstream_oid) {
343        Ok(base) => {
344            let base_oid = base.detach();
345            let ahead = count_ancestors_excluding(repo, head_oid, base_oid)?;
346            let behind = count_ancestors_excluding(repo, upstream_oid, base_oid)?;
347            (ahead, behind)
348        }
349        // Unrelated histories → hide per spec §Ahead/behind
350        // computation. Other merge_base errors (cache open, walker
351        // crash) bubble so the outer accessor surfaces them.
352        Err(gix::repository::merge_base::Error::NotFound { .. }) => return Ok(None),
353        Err(e) => return Err(Box::new(e)),
354    };
355
356    let full_name = upstream_ref_name.as_bstr().to_string();
357    let upstream_branch = match full_name.strip_prefix("refs/remotes/") {
358        Some(short) => short.to_string(),
359        None => {
360            crate::lsm_warn!(
361                "upstream ref {full_name} is outside refs/remotes/; rendering full refname"
362            );
363            full_name
364        }
365    };
366
367    Ok(Some(UpstreamState {
368        ahead: u32::try_from(ahead).map_err(|_| {
369            Box::<dyn std::error::Error>::from(format!("ahead count {ahead} overflows u32"))
370        })?,
371        behind: u32::try_from(behind).map_err(|_| {
372            Box::<dyn std::error::Error>::from(format!("behind count {behind} overflows u32"))
373        })?,
374        upstream_branch,
375    }))
376}
377
378/// Count commits reachable from `tip` but not from `stop` (and not
379/// `stop` itself). No visited-set needed: `gix::rev_walk` emits
380/// each OID at most once per walk.
381fn count_ancestors_excluding(
382    repo: &gix::Repository,
383    tip: gix::ObjectId,
384    stop: gix::ObjectId,
385) -> Result<usize, Box<dyn std::error::Error>> {
386    use std::collections::HashSet;
387    if tip == stop {
388        return Ok(0);
389    }
390    let mut excluded: HashSet<gix::ObjectId> = HashSet::new();
391    excluded.insert(stop);
392    for info in repo.rev_walk([stop]).all()? {
393        excluded.insert(info?.id);
394    }
395
396    let mut count = 0usize;
397    for info in repo.rev_walk([tip]).all()? {
398        if !excluded.contains(&info?.id) {
399            count += 1;
400        }
401    }
402    Ok(count)
403}
404
405fn classify_kind(repo: &gix::Repository) -> RepoKind {
406    // gix 0.82 consolidated `Kind::Bare` and `Kind::WorkTree { is_linked: false }`
407    // into `Kind::Common`; bare-ness now reads from `Repository::is_bare()`
408    // which consults the loaded config rather than the directory layout.
409    if repo.is_bare() {
410        return RepoKind::Bare;
411    }
412    match repo.kind() {
413        gix::repository::Kind::Common => RepoKind::Main,
414        gix::repository::Kind::LinkedWorkTree => {
415            // `.git/worktrees/<name>/` — basename of the gitdir is the
416            // per-worktree label.
417            let name = repo
418                .git_dir()
419                .file_name()
420                .and_then(|s| s.to_str())
421                .unwrap_or("")
422                .to_string();
423            RepoKind::LinkedWorktree { name }
424        }
425        gix::repository::Kind::Submodule => RepoKind::Submodule,
426    }
427}
428
429fn resolve_head(repo: &gix::Repository) -> Result<Head, String> {
430    let head = repo.head().map_err(|e| e.to_string())?;
431    match head.kind {
432        gix::head::Kind::Symbolic(reference) => {
433            let full = reference.name.as_bstr().to_string();
434            match full.strip_prefix("refs/heads/") {
435                Some(short) => Ok(Head::Branch(short.to_string())),
436                None => Ok(Head::OtherRef { full_name: full }),
437            }
438        }
439        gix::head::Kind::Detached { target, peeled: _ } => Ok(Head::Detached(target)),
440        gix::head::Kind::Unborn(refname) => {
441            let full = refname.as_bstr().to_string();
442            match full.strip_prefix("refs/heads/") {
443                Some(short) => Ok(Head::Unborn {
444                    symbolic_ref: short.to_string(),
445                }),
446                None => Ok(Head::OtherRef { full_name: full }),
447            }
448        }
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use std::fs;
456    use tempfile::TempDir;
457
458    fn init_repo(dir: &Path) -> gix::Repository {
459        gix::init(dir).expect("gix::init")
460    }
461
462    #[test]
463    fn non_repo_directory_returns_ok_none() {
464        let tmp = TempDir::new().expect("tmp");
465        // Nested subdir with no .git anywhere up the chain. `gix`
466        // discover walks up the tempdir which lives under /var/folders;
467        // tempdirs do not have .git parents on any OS we support.
468        let sub = tmp.path().join("nested");
469        fs::create_dir_all(&sub).expect("mkdir");
470        assert!(resolve_repo(&sub).expect("resolve").is_none());
471    }
472
473    #[test]
474    fn main_checkout_classifies_as_main() {
475        let tmp = TempDir::new().expect("tmp");
476        init_repo(tmp.path());
477        let ctx = resolve_repo(tmp.path()).expect("resolve").expect("some");
478        assert_eq!(ctx.repo_kind, RepoKind::Main);
479    }
480
481    #[test]
482    fn bare_repo_classifies_as_bare() {
483        let tmp = TempDir::new().expect("tmp");
484        gix::init_bare(tmp.path()).expect("init_bare");
485        let ctx = resolve_repo(tmp.path()).expect("resolve").expect("some");
486        assert_eq!(ctx.repo_kind, RepoKind::Bare);
487    }
488
489    /// Fabricate the on-disk layout `git worktree add` produces without
490    /// shelling out. Primary has a real `.git/`; the worktree checkout has
491    /// a `.git` file pointing at `<primary>/.git/worktrees/<name>/`, which
492    /// holds the per-worktree `HEAD`, `commondir`, and `gitdir`.
493    fn hand_built_linked_worktree(name: &str, primary: &Path, wt_root: &Path) -> PathBuf {
494        let primary_git = primary.join(".git");
495        fs::create_dir_all(primary_git.join("refs/heads")).expect("mkdir refs/heads");
496        fs::create_dir_all(primary_git.join("objects")).expect("mkdir objects");
497        fs::write(primary_git.join("HEAD"), "ref: refs/heads/main\n").expect("write primary HEAD");
498
499        let admin_dir = primary_git.join("worktrees").join(name);
500        fs::create_dir_all(&admin_dir).expect("mkdir admin");
501        let worktree_branch = format!("wt-{name}");
502        fs::write(
503            admin_dir.join("HEAD"),
504            format!("ref: refs/heads/{worktree_branch}\n"),
505        )
506        .expect("write admin HEAD");
507        fs::write(admin_dir.join("commondir"), "../..\n").expect("write commondir");
508
509        let worktree_dir = wt_root.join(name);
510        fs::create_dir_all(&worktree_dir).expect("mkdir worktree");
511        fs::write(
512            admin_dir.join("gitdir"),
513            format!("{}\n", worktree_dir.join(".git").display()),
514        )
515        .expect("write gitdir");
516
517        fs::write(
518            worktree_dir.join(".git"),
519            format!("gitdir: {}\n", admin_dir.display()),
520        )
521        .expect("write .git pointer");
522
523        worktree_dir
524    }
525
526    #[test]
527    fn resolve_repo_classifies_hand_built_linked_worktree() {
528        let primary_tmp = TempDir::new().expect("primary");
529        let wt_tmp = TempDir::new().expect("wt root");
530        let worktree = hand_built_linked_worktree("feat-abc", primary_tmp.path(), wt_tmp.path());
531
532        let ctx = resolve_repo(&worktree).expect("resolve").expect("some");
533
534        let RepoKind::LinkedWorktree { name } = &ctx.repo_kind else {
535            panic!("expected LinkedWorktree, got {:?}", ctx.repo_kind);
536        };
537        assert_eq!(name, "feat-abc");
538        assert!(
539            ctx.repo_path.ends_with("worktrees/feat-abc"),
540            "repo_path should point at the per-worktree admin dir, got {:?}",
541            ctx.repo_path
542        );
543        match &ctx.head {
544            Head::Unborn { symbolic_ref } => assert_eq!(
545                symbolic_ref, "wt-feat-abc",
546                "head must come from the worktree admin HEAD, not the primary's"
547            ),
548            other => panic!("expected Unborn(wt-feat-abc), got {other:?}"),
549        }
550    }
551
552    #[test]
553    fn classify_kind_returns_basename_for_real_linked_worktree() {
554        let primary = TempDir::new().expect("primary");
555        let wt_parent = TempDir::new().expect("wt parent");
556        run_git_init(primary.path());
557        run_git_commit_allow_empty(primary.path(), "seed");
558        let worktree_dir = wt_parent.path().join("feat-real-wt");
559        run_git(
560            primary.path(),
561            &[
562                "worktree",
563                "add",
564                "--quiet",
565                "-b",
566                "feat-real-wt",
567                worktree_dir.to_str().expect("utf8 path"),
568            ],
569        );
570
571        let ctx = resolve_repo(&worktree_dir).expect("resolve").expect("some");
572        let RepoKind::LinkedWorktree { name } = &ctx.repo_kind else {
573            panic!("expected LinkedWorktree, got {:?}", ctx.repo_kind);
574        };
575        assert_eq!(name, "feat-real-wt");
576        match &ctx.head {
577            Head::Branch(b) => assert_eq!(b, "feat-real-wt"),
578            other => panic!("expected Branch(feat-real-wt), got {other:?}"),
579        }
580    }
581
582    #[test]
583    fn unborn_head_reports_symbolic_ref_target() {
584        let tmp = TempDir::new().expect("tmp");
585        init_repo(tmp.path());
586        let ctx = resolve_repo(tmp.path()).expect("resolve").expect("some");
587        match &ctx.head {
588            Head::Unborn { symbolic_ref } => {
589                // `gix::init` defaults to `main` unless init.defaultBranch
590                // is configured; we accept either `main` or `master` so
591                // this runs on systems with legacy defaults.
592                assert!(
593                    symbolic_ref == "main" || symbolic_ref == "master",
594                    "unexpected default branch: {symbolic_ref}"
595                );
596            }
597            other => panic!("expected Unborn, got {other:?}"),
598        }
599    }
600
601    #[test]
602    fn dirty_is_clean_when_no_gix_repo_held() {
603        let ctx = GitContext::new(
604            RepoKind::Main,
605            PathBuf::from("/tmp/.git"),
606            Head::Branch("main".into()),
607        );
608        assert_eq!(*ctx.dirty(), DirtyState::Clean);
609    }
610
611    /// Build a repo with one committed tracked file. Returns the
612    /// fixture path so callers can add untracked files / modify
613    /// tracked ones and re-scan.
614    fn fixture_with_commit(tmp: &TempDir) -> &Path {
615        use std::fs;
616        let path = tmp.path();
617        // Fixture setup shells out to the `git` binary; fabricating
618        // an index + initial commit via gix would take dozens of
619        // lines per test. Production code paths stay gix-only.
620        run_git_init(path);
621        run_git_commit_allow_empty(path, "seed");
622        fs::write(path.join("tracked.txt"), "v1").expect("write");
623        run_git(path, &["add", "tracked.txt"]);
624        run_git_commit(path, "tracked");
625        path
626    }
627
628    fn run_git_init(path: &Path) {
629        use std::process::Command;
630        let mut cmd = Command::new("git");
631        isolated_git_env(&mut cmd);
632        let status = cmd
633            .args(["init", "--quiet", "--initial-branch=main"])
634            .current_dir(path)
635            .status()
636            .expect("git init");
637        assert!(status.success(), "git init failed in {path:?}");
638    }
639
640    fn run_git_init_bare(path: &Path) {
641        use std::process::Command;
642        let mut cmd = Command::new("git");
643        isolated_git_env(&mut cmd);
644        let status = cmd
645            .args(["init", "--bare", "--quiet", "--initial-branch=main"])
646            .current_dir(path)
647            .status()
648            .expect("git init --bare");
649        assert!(status.success(), "git init --bare failed in {path:?}");
650    }
651
652    fn run_git_commit_allow_empty(cwd: &Path, msg: &str) {
653        use std::process::Command;
654        let mut cmd = Command::new("git");
655        isolated_git_env(&mut cmd);
656        let status = cmd
657            .args(["-c", "user.email=t@t", "-c", "user.name=t", "-C"])
658            .arg(cwd)
659            .args(["commit", "--allow-empty", "-m", msg, "--quiet"])
660            .status()
661            .expect("git commit");
662        assert!(
663            status.success(),
664            "git commit --allow-empty failed in {cwd:?}"
665        );
666    }
667
668    #[test]
669    fn dirty_detects_untracked_file() {
670        use std::fs;
671        let tmp = TempDir::new().expect("tmp");
672        let path = fixture_with_commit(&tmp);
673        fs::write(path.join("new.txt"), "hello").expect("write");
674        let ctx = resolve_repo(path).expect("resolve").expect("some");
675        assert!(
676            ctx.dirty().is_dirty(),
677            "expected dirty on untracked, got {:?}",
678            ctx.dirty()
679        );
680    }
681
682    #[test]
683    fn dirty_detects_modified_tracked_file() {
684        use std::fs;
685        let tmp = TempDir::new().expect("tmp");
686        let path = fixture_with_commit(&tmp);
687        fs::write(path.join("tracked.txt"), "modified").expect("write");
688        let ctx = resolve_repo(path).expect("resolve").expect("some");
689        assert!(
690            ctx.dirty().is_dirty(),
691            "expected dirty on modified tracked, got {:?}",
692            ctx.dirty()
693        );
694    }
695
696    #[test]
697    fn dirty_is_clean_on_committed_repo_with_no_changes() {
698        let tmp = TempDir::new().expect("tmp");
699        let path = fixture_with_commit(&tmp);
700        let ctx = resolve_repo(path).expect("resolve").expect("some");
701        assert_eq!(*ctx.dirty(), DirtyState::Clean);
702    }
703
704    #[test]
705    fn upstream_is_none_when_no_gix_repo_held() {
706        let ctx = GitContext::new(
707            RepoKind::Main,
708            PathBuf::from("/tmp/.git"),
709            Head::Branch("main".into()),
710        );
711        assert!(ctx.upstream().is_none());
712    }
713
714    #[test]
715    fn upstream_is_none_when_no_tracking_branch_configured() {
716        let tmp = TempDir::new().expect("tmp");
717        let path = fixture_with_commit(&tmp);
718        let ctx = resolve_repo(path).expect("resolve").expect("some");
719        assert!(
720            ctx.upstream().is_none(),
721            "expected None without upstream, got {:?}",
722            ctx.upstream()
723        );
724    }
725
726    /// Build local + bare-remote fixture with HEAD tracking
727    /// `origin/main`. `local_commits` extra commits on top of the
728    /// shared base stay local (ahead); `remote_commits` land in the
729    /// bare remote and are fetched without updating HEAD (behind).
730    fn fixture_with_upstream<'a>(
731        local: &'a TempDir,
732        remote: &'a TempDir,
733        local_commits: usize,
734        remote_commits: usize,
735    ) -> &'a Path {
736        use std::fs;
737        use std::process::Command;
738        let bare = remote.path();
739        let path = local.path();
740        run_git_init_bare(bare);
741        run_git_init(path);
742        fs::write(path.join("f"), "base").expect("write base");
743        run_git(path, &["add", "f"]);
744        run_git_commit(path, "base");
745        run_git(
746            path,
747            &["remote", "add", "origin", bare.to_str().expect("utf8 path")],
748        );
749        run_git(path, &["push", "-u", "origin", "main", "--quiet"]);
750        for i in 0..local_commits {
751            fs::write(path.join("f"), format!("local-{i}")).expect("write");
752            run_git(path, &["add", "f"]);
753            run_git_commit(path, &format!("local {i}"));
754        }
755        // Diverge from the remote side by cloning into a unique
756        // TempDir (so parallel tests don't collide), adding commits
757        // there, pushing back, and fetching locally.
758        if remote_commits > 0 {
759            let other_tmp = TempDir::new().expect("other tmp");
760            let other = other_tmp.path().join("clone");
761            let mut clone_cmd = Command::new("git");
762            isolated_git_env(&mut clone_cmd);
763            let status = clone_cmd
764                .args(["clone", "--quiet"])
765                .arg(bare)
766                .arg(&other)
767                .status()
768                .expect("clone");
769            assert!(status.success(), "git clone failed");
770            for i in 0..remote_commits {
771                fs::write(other.join("g"), format!("remote-{i}")).expect("write");
772                run_git(&other, &["add", "g"]);
773                run_git_commit(&other, &format!("remote {i}"));
774            }
775            run_git(&other, &["push", "--quiet"]);
776            run_git(path, &["fetch", "--quiet"]);
777            drop(other_tmp);
778        }
779        path
780    }
781
782    /// Env vars that neutralize the test host's global / system git
783    /// config. A dev with `commit.gpgsign = true`, `core.hooksPath`,
784    /// or `safe.directory` denials set globally would otherwise see
785    /// spurious fixture failures unrelated to the code under test.
786    fn isolated_git_env(cmd: &mut std::process::Command) {
787        cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
788            .env("GIT_CONFIG_SYSTEM", "/dev/null")
789            .env("GIT_CONFIG_NOSYSTEM", "1")
790            .args(["-c", "commit.gpgsign=false"])
791            .args(["-c", "core.hooksPath=/dev/null"])
792            .args(["-c", "init.defaultBranch=main"]);
793    }
794
795    fn run_git(cwd: &Path, args: &[&str]) {
796        use std::process::Command;
797        let mut cmd = Command::new("git");
798        isolated_git_env(&mut cmd);
799        let status = cmd.args(["-C"]).arg(cwd).args(args).status().expect("git");
800        assert!(status.success(), "git {args:?} failed in {cwd:?}");
801    }
802
803    fn run_git_commit(cwd: &Path, msg: &str) {
804        use std::process::Command;
805        let mut cmd = Command::new("git");
806        isolated_git_env(&mut cmd);
807        let status = cmd
808            .args(["-c", "user.email=t@t", "-c", "user.name=t", "-C"])
809            .arg(cwd)
810            .args(["commit", "-m", msg, "--quiet"])
811            .status()
812            .expect("git commit");
813        assert!(status.success(), "git commit failed in {cwd:?}");
814    }
815
816    #[test]
817    fn upstream_reports_zero_ahead_zero_behind_when_in_sync() {
818        let local = TempDir::new().expect("local");
819        let remote = TempDir::new().expect("remote");
820        let path = fixture_with_upstream(&local, &remote, 0, 0);
821        let ctx = resolve_repo(path).expect("resolve").expect("some");
822        let upstream = ctx.upstream();
823        let state = upstream.as_ref().as_ref().expect("some upstream");
824        assert_eq!(state.ahead, 0);
825        assert_eq!(state.behind, 0);
826        assert_eq!(state.upstream_branch, "origin/main");
827    }
828
829    #[test]
830    fn upstream_reports_ahead_only_when_local_leads() {
831        let local = TempDir::new().expect("local");
832        let remote = TempDir::new().expect("remote");
833        let path = fixture_with_upstream(&local, &remote, 2, 0);
834        let ctx = resolve_repo(path).expect("resolve").expect("some");
835        let upstream = ctx.upstream();
836        let state = upstream.as_ref().as_ref().expect("some upstream");
837        assert_eq!(state.ahead, 2);
838        assert_eq!(state.behind, 0);
839    }
840
841    #[test]
842    fn upstream_reports_behind_only_when_remote_leads() {
843        let local = TempDir::new().expect("local");
844        let remote = TempDir::new().expect("remote");
845        let path = fixture_with_upstream(&local, &remote, 0, 3);
846        let ctx = resolve_repo(path).expect("resolve").expect("some");
847        let upstream = ctx.upstream();
848        let state = upstream.as_ref().as_ref().expect("some upstream");
849        assert_eq!(state.ahead, 0);
850        assert_eq!(state.behind, 3);
851    }
852
853    #[test]
854    fn upstream_reports_both_when_diverged() {
855        let local = TempDir::new().expect("local");
856        let remote = TempDir::new().expect("remote");
857        let path = fixture_with_upstream(&local, &remote, 2, 3);
858        let ctx = resolve_repo(path).expect("resolve").expect("some");
859        let upstream = ctx.upstream();
860        let state = upstream.as_ref().as_ref().expect("some upstream");
861        assert_eq!(state.ahead, 2);
862        assert_eq!(state.behind, 3);
863    }
864
865    #[test]
866    fn upstream_is_none_on_detached_head() {
867        let tmp = TempDir::new().expect("tmp");
868        let path = fixture_with_commit(&tmp);
869        // Detach HEAD at the current commit.
870        run_git(path, &["checkout", "--detach", "HEAD"]);
871        let ctx = resolve_repo(path).expect("resolve").expect("some");
872        assert!(matches!(ctx.head, Head::Detached(_)));
873        assert!(ctx.upstream().is_none());
874    }
875
876    #[test]
877    fn head_kind_str_covers_every_variant() {
878        assert_eq!(Head::Branch("x".into()).kind_str(), "branch");
879        assert_eq!(
880            Head::Detached(gix::ObjectId::null(gix::hash::Kind::Sha1)).kind_str(),
881            "detached"
882        );
883        assert_eq!(
884            Head::Unborn {
885                symbolic_ref: "main".into()
886            }
887            .kind_str(),
888            "unborn"
889        );
890        assert_eq!(
891            Head::OtherRef {
892                full_name: "refs/remotes/origin/main".into()
893            }
894            .kind_str(),
895            "other_ref"
896        );
897    }
898}