weavatrix-git 0.2.0

Dependency-free, evidence-carrying Git repository reader
Documentation
use std::{collections::BTreeMap, fs, path::Path};

use crate::{EntryKind, GitError, IndexEntry, ObjectKind, Repository, Result, error::invalid};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StatusKind {
    Unmodified,
    Added,
    Modified,
    Deleted,
    TypeChanged,
    Unmerged,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StatusEntry {
    pub path: Vec<u8>,
    pub index: StatusKind,
    pub worktree: StatusKind,
}

#[derive(Clone, Copy)]
struct HeadEntry {
    mode: u32,
    id: crate::ObjectId,
}

impl Repository {
    pub fn status(&self) -> Result<Vec<StatusEntry>> {
        let work_dir = self
            .work_dir()
            .ok_or_else(|| GitError::Unsupported("status in a bare repository".to_owned()))?;
        let index = self.index_shared()?;
        let mut head = self.head_entries()?;
        let mut result = Vec::new();
        for entry in index.entries() {
            if entry.stage > 0 {
                result.push(StatusEntry {
                    path: entry.path.clone(),
                    index: StatusKind::Unmerged,
                    worktree: StatusKind::Unmerged,
                });
                continue;
            }
            let staged = staged(entry, head.remove(&entry.path));
            let working = working(self, work_dir, entry)?;
            if staged != StatusKind::Unmodified || working != StatusKind::Unmodified {
                result.push(StatusEntry {
                    path: entry.path.clone(),
                    index: staged,
                    worktree: working,
                });
            }
        }
        result.extend(head.into_keys().map(|path| StatusEntry {
            path,
            index: StatusKind::Deleted,
            worktree: StatusKind::Unmodified,
        }));
        result.sort_unstable_by(|left, right| left.path.cmp(&right.path));
        Ok(result)
    }

    fn head_entries(&self) -> Result<BTreeMap<Vec<u8>, HeadEntry>> {
        let Some(head) = self.head()?.target else {
            return Ok(BTreeMap::new());
        };
        let root = self.commit(head)?.tree;
        let mut pending = vec![(Vec::new(), root, 0_usize)];
        let mut result = BTreeMap::new();
        while let Some((prefix, tree, depth)) = pending.pop() {
            if depth > self.limits().max_tree_depth {
                return Err(GitError::LimitExceeded {
                    resource: "status tree depth",
                    limit: self.limits().max_tree_depth,
                });
            }
            for entry in self.tree(tree)?.entries {
                let mut path = prefix.clone();
                if !path.is_empty() {
                    path.push(b'/');
                }
                path.extend(&entry.name);
                if entry.kind == EntryKind::Tree {
                    pending.push((path, entry.id, depth + 1));
                } else {
                    result.insert(
                        path,
                        HeadEntry {
                            mode: entry.mode,
                            id: entry.id,
                        },
                    );
                }
            }
        }
        Ok(result)
    }
}

fn staged(index: &IndexEntry, head: Option<HeadEntry>) -> StatusKind {
    let Some(head) = head else {
        return StatusKind::Added;
    };
    if mode_kind(index.mode) != mode_kind(head.mode) {
        StatusKind::TypeChanged
    } else if index.id != head.id || index.mode != head.mode {
        StatusKind::Modified
    } else {
        StatusKind::Unmodified
    }
}

fn working(repository: &Repository, root: &Path, entry: &IndexEntry) -> Result<StatusKind> {
    if entry.skip_worktree || entry.intent_to_add {
        return Ok(StatusKind::Unmodified);
    }
    let path_text = std::str::from_utf8(&entry.path)
        .map_err(|_| GitError::Unsupported("non-UTF-8 status path".to_owned()))?;
    let path = root.join(path_text);
    let metadata = match fs::symlink_metadata(&path) {
        Ok(metadata) => metadata,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return Ok(StatusKind::Deleted);
        }
        Err(error) => return Err(error.into()),
    };
    match mode_kind(entry.mode) {
        0o100_000 if metadata.is_file() => compare_file(repository, &path, entry),
        0o120_000 if metadata.file_type().is_symlink() => {
            let target = fs::read_link(path)?;
            let target = target
                .to_str()
                .ok_or_else(|| GitError::Unsupported("non-UTF-8 symlink target".to_owned()))?;
            compare_bytes(repository, target.as_bytes(), entry)
        }
        0o160_000 if metadata.is_dir() => Err(GitError::Unsupported(
            "submodule worktree status".to_owned(),
        )),
        _ => Ok(StatusKind::TypeChanged),
    }
}

fn compare_file(repository: &Repository, path: &Path, entry: &IndexEntry) -> Result<StatusKind> {
    let length = usize::try_from(fs::metadata(path)?.len())
        .map_err(|_| invalid("working file length overflow"))?;
    if length > repository.limits().max_object_bytes {
        return Err(GitError::LimitExceeded {
            resource: "status file bytes",
            limit: repository.limits().max_object_bytes,
        });
    }
    compare_bytes(repository, &fs::read(path)?, entry)
}

fn compare_bytes(repository: &Repository, actual: &[u8], entry: &IndexEntry) -> Result<StatusKind> {
    let object = repository.object(entry.id)?;
    if object.kind != ObjectKind::Blob {
        return Err(invalid("index entry does not reference a blob"));
    }
    Ok(if actual == object.data {
        StatusKind::Unmodified
    } else {
        StatusKind::Modified
    })
}

const fn mode_kind(mode: u32) -> u32 {
    mode & 0o170_000
}