weavatrix-git 0.3.2

Fast, bounded, evidence-carrying Git reader with an optional read-only MCP server
Documentation
//! Worktree safety summary for consumers that must not invent Git state.

use std::{collections::BTreeSet, fs};

use crate::{
    Repository, Result, StatusKind, gitignore::IgnoreStack, worktree_walk::count_untracked,
};

/// How the repository is attached to a working tree.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WorktreeKind {
    /// No worktree.
    Bare,
    /// Primary checkout whose `.git` is a directory.
    Primary,
    /// Linked worktree whose `.git` is a `gitdir:` file.
    Linked,
}

/// Coarse safety bucket. Ignored-only is not dirty.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WorktreeSafetyLevel {
    /// No tracked changes and no untracked files.
    Clean,
    /// Tracked state is clean; extras are ignored only.
    IgnoredOnly,
    /// Untracked, non-ignored files exist.
    HasUntracked,
    /// Index or worktree tracked files differ from HEAD.
    DirtyTracked,
    /// Safety could not be determined (bare, I/O, submodule limits).
    Unknown,
}

/// One inspectable reason behind the summary.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WorktreeEvidence {
    /// Tracked path with a non-clean status.
    TrackedDirty {
        /// Exact Git path bytes.
        path: Vec<u8>,
        /// Index versus HEAD.
        index: StatusKind,
        /// Worktree versus index.
        worktree: StatusKind,
    },
    /// Sample untracked path.
    Untracked {
        /// Exact relative path bytes.
        path: Vec<u8>,
    },
    /// Sample ignored path.
    Ignored {
        /// Exact relative path bytes.
        path: Vec<u8>,
    },
    /// A gitlink was present and was not inspected.
    Submodule {
        /// Exact relative path bytes.
        path: Vec<u8>,
    },
}

/// Portable worktree safety contract for cleaners and agent worktrees.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorktreeSafety {
    /// Tracked files differ from HEAD in the index or worktree.
    pub tracked_dirty: bool,
    /// Index differs from HEAD.
    pub staged_dirty: bool,
    /// Untracked, non-ignored paths.
    pub untracked_count: u64,
    /// Ignored untracked paths (directory roots counted once).
    pub ignored_count: u64,
    /// At least one gitlink exists; submodule contents were not scanned.
    pub submodule_unknown: bool,
    /// Worktree layout.
    pub kind: WorktreeKind,
    /// Coarse bucket derived from the counts.
    pub level: WorktreeSafetyLevel,
    /// Bounded evidence samples.
    pub evidence: Vec<WorktreeEvidence>,
}

impl WorktreeSafety {
    fn from_parts(
        kind: WorktreeKind,
        tracked_dirty: bool,
        staged_dirty: bool,
        untracked_count: u64,
        ignored_count: u64,
        submodule_unknown: bool,
        evidence: Vec<WorktreeEvidence>,
    ) -> Self {
        let level = if kind == WorktreeKind::Bare {
            WorktreeSafetyLevel::Unknown
        } else if tracked_dirty {
            WorktreeSafetyLevel::DirtyTracked
        } else if untracked_count > 0 {
            WorktreeSafetyLevel::HasUntracked
        } else if ignored_count > 0 {
            WorktreeSafetyLevel::IgnoredOnly
        } else {
            WorktreeSafetyLevel::Clean
        };
        Self {
            tracked_dirty,
            staged_dirty,
            untracked_count,
            ignored_count,
            submodule_unknown,
            kind,
            level,
            evidence,
        }
    }
}

impl Repository {
    /// Classify the worktree without launching Git.
    ///
    /// Bare repositories return [`WorktreeSafetyLevel::Unknown`]. Tracked
    /// status is reused from [`Self::status`]; untracked and ignored paths are
    /// counted from a symlink-free walk that honors `.gitignore` and
    /// `$GIT_DIR/info/exclude`.
    pub fn worktree_safety(&self) -> Result<WorktreeSafety> {
        let kind = worktree_kind(self);
        let Some(root) = self.work_dir() else {
            return Ok(WorktreeSafety::from_parts(
                kind,
                false,
                false,
                0,
                0,
                false,
                Vec::new(),
            ));
        };
        let status = self.status()?;
        let mut staged_dirty = false;
        let mut tracked_dirty = false;
        let mut evidence = Vec::new();
        for entry in &status {
            let staged = entry.index != StatusKind::Unmodified;
            let dirty = staged || entry.worktree != StatusKind::Unmodified;
            staged_dirty |= staged;
            tracked_dirty |= dirty;
            if dirty && evidence.len() < 32 {
                evidence.push(WorktreeEvidence::TrackedDirty {
                    path: entry.path.clone(),
                    index: entry.index,
                    worktree: entry.worktree,
                });
            }
        }
        let index = self.index_shared()?;
        let mut submodule_unknown = false;
        let mut indexed = BTreeSet::new();
        for entry in index.entries() {
            indexed.insert(entry.path.clone());
            if entry.mode & 0o170_000 == 0o160_000 {
                submodule_unknown = true;
                evidence.push(WorktreeEvidence::Submodule {
                    path: entry.path.clone(),
                });
            }
        }
        let mut ignore = IgnoreStack::default();
        if let Ok(text) = fs::read_to_string(self.git_dir().join("info").join("exclude")) {
            ignore.push_file("", &text);
        }
        let counts = count_untracked(root, &indexed, &ignore, self.limits().max_index_entries)?;
        evidence.extend(
            counts
                .untracked_samples
                .into_iter()
                .map(|path| WorktreeEvidence::Untracked { path }),
        );
        evidence.extend(
            counts
                .ignored_samples
                .into_iter()
                .map(|path| WorktreeEvidence::Ignored { path }),
        );
        Ok(WorktreeSafety::from_parts(
            kind,
            tracked_dirty,
            staged_dirty,
            counts.untracked,
            counts.ignored,
            submodule_unknown,
            evidence,
        ))
    }
}

fn worktree_kind(repository: &Repository) -> WorktreeKind {
    let Some(work_dir) = repository.work_dir() else {
        return WorktreeKind::Bare;
    };
    if work_dir.join(".git").is_file() {
        WorktreeKind::Linked
    } else {
        WorktreeKind::Primary
    }
}

impl WorktreeSafety {
    /// Unknown safety used when the caller cannot open the repository.
    #[must_use]
    pub fn unknown() -> Self {
        Self::from_parts(WorktreeKind::Bare, false, false, 0, 0, false, Vec::new())
    }
}