weavatrix-git 0.3.4

Read-only Git evidence: library, CLI, and optional MCP server. Never mutates a repository.
Documentation
//! Worktree walk that classifies untracked versus ignored paths.

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

use crate::{GitError, Result, gitignore::IgnoreStack};

#[derive(Default)]
pub(crate) struct WalkCounts {
    pub untracked: u64,
    pub ignored: u64,
    pub visited: u64,
    pub untracked_samples: Vec<Vec<u8>>,
    pub ignored_samples: Vec<Vec<u8>>,
}

const SAMPLE_LIMIT: usize = 32;

pub(crate) fn count_untracked(
    root: &Path,
    indexed: &BTreeSet<Vec<u8>>,
    ignore: &IgnoreStack,
    limit: usize,
) -> Result<WalkCounts> {
    let mut counts = WalkCounts::default();
    walk(root, root, "", indexed, ignore, limit, &mut counts)?;
    Ok(counts)
}

fn walk(
    root: &Path,
    dir: &Path,
    relative: &str,
    indexed: &BTreeSet<Vec<u8>>,
    parent_ignore: &IgnoreStack,
    limit: usize,
    counts: &mut WalkCounts,
) -> Result<()> {
    if counts.visited >= limit as u64 {
        return Err(GitError::LimitExceeded {
            resource: "worktree entries",
            limit,
        });
    }
    let mut ignore = parent_ignore.clone();
    if let Ok(text) = fs::read_to_string(dir.join(".gitignore")) {
        ignore.push_file(relative, &text);
    }
    for entry in fs::read_dir(dir)? {
        classify_dirent(root, indexed, &ignore, limit, counts, relative, &entry?)?;
    }
    Ok(())
}

fn classify_dirent(
    root: &Path,
    indexed: &BTreeSet<Vec<u8>>,
    ignore: &IgnoreStack,
    limit: usize,
    counts: &mut WalkCounts,
    relative: &str,
    entry: &fs::DirEntry,
) -> Result<()> {
    counts.visited += 1;
    if counts.visited > limit as u64 {
        return Err(GitError::LimitExceeded {
            resource: "worktree entries",
            limit,
        });
    }
    let name = entry.file_name();
    if name == ".git" {
        return Ok(());
    }
    let path = entry.path();
    let metadata = fs::symlink_metadata(&path)?;
    if metadata.file_type().is_symlink() {
        return Ok(());
    }
    let child_rel = join_relative(relative, &name);
    let is_dir = metadata.is_dir();
    let bytes = child_rel.as_bytes().to_vec();
    if is_dir && is_tracked_dir(indexed, &bytes) {
        return walk(root, &path, &child_rel, indexed, ignore, limit, counts);
    }
    if indexed.contains(&bytes) {
        return Ok(());
    }
    if ignore.is_ignored(&child_rel, is_dir) {
        counts.ignored += 1;
        push_sample(&mut counts.ignored_samples, bytes);
        return Ok(());
    }
    counts.untracked += 1;
    push_sample(&mut counts.untracked_samples, bytes);
    if is_dir {
        walk(root, &path, &child_rel, indexed, ignore, limit, counts)?;
    }
    Ok(())
}

fn join_relative(prefix: &str, name: &std::ffi::OsStr) -> String {
    let name = name.to_string_lossy();
    if prefix.is_empty() {
        name.into_owned()
    } else {
        format!("{prefix}/{name}")
    }
}

fn is_tracked_dir(indexed: &BTreeSet<Vec<u8>>, path: &[u8]) -> bool {
    let mut prefix = path.to_vec();
    prefix.push(b'/');
    indexed
        .range(prefix.clone()..)
        .next()
        .is_some_and(|candidate| candidate.starts_with(&prefix))
}

fn push_sample(samples: &mut Vec<Vec<u8>>, path: Vec<u8>) {
    if samples.len() < SAMPLE_LIMIT {
        samples.push(path);
    }
}