skillnet 0.6.0

Manage canonical AI skill stores, derived views, and calibration data for multi-phase-plan.
Documentation
use std::process::Command;

use anyhow::{bail, Context, Result};
use camino::{Utf8Path, Utf8PathBuf};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DirtyKind {
    Modified,
    Untracked,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DirtyEntry {
    pub kind: DirtyKind,
    pub paths: Vec<Utf8PathBuf>,
}

#[derive(Debug, Clone)]
pub(crate) struct GitStatus {
    pub root: Utf8PathBuf,
    pub branch: Option<String>,
    pub remote: Option<String>,
    pub dirty_entries: usize,
}

impl GitStatus {
    pub(crate) fn is_dirty(&self) -> bool {
        self.dirty_entries > 0
    }
}

pub(crate) fn status(root: &Utf8Path) -> Result<Option<GitStatus>> {
    if !root.join(".git").exists() {
        return Ok(None);
    }

    let branch = git_output(root, ["branch", "--show-current"])?;
    let remote = git_output(root, ["remote", "get-url", "origin"]).ok();
    let porcelain = git_output(root, ["status", "--porcelain"])?;
    let dirty = parse_porcelain(&porcelain)?;
    let dirty_entries = dirty.len();

    Ok(Some(GitStatus {
        root: root.to_path_buf(),
        branch: non_empty(branch),
        remote: remote.and_then(non_empty),
        dirty_entries,
    }))
}

pub(crate) fn ensure_clean(root: &Utf8Path) -> Result<()> {
    let Some(status) = status(root)? else {
        return Ok(());
    };

    if status.is_dirty() {
        bail!(
            "destination repository `{}` has {} dirty entries; commit, stash, or pass --allow-dirty-destination",
            status.root,
            status.dirty_entries
        );
    }
    Ok(())
}

fn git_output<const N: usize>(root: &Utf8Path, args: [&str; N]) -> Result<String> {
    let output = Command::new("git")
        .args(args)
        .current_dir(root)
        .output()
        .with_context(|| format!("failed to run git in {root}"))?;

    if !output.status.success() {
        bail!(
            "git command failed in `{}`: {}",
            root,
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

fn non_empty(value: String) -> Option<String> {
    (!value.trim().is_empty()).then_some(value)
}

fn parse_porcelain(porcelain: &str) -> Result<Vec<DirtyEntry>> {
    porcelain
        .lines()
        .filter(|line| !line.trim().is_empty())
        .map(parse_porcelain_line)
        .collect()
}

fn parse_porcelain_line(line: &str) -> Result<DirtyEntry> {
    if line.len() < 4 {
        bail!("unsupported git status line: {line}");
    }

    let code = &line[..2];
    let raw_path = &line[3..];
    let paths = if matches!(code.chars().next(), Some('R' | 'C')) {
        raw_path
            .split(" -> ")
            .map(parse_dirty_path)
            .collect::<Result<Vec<_>>>()?
    } else {
        vec![parse_dirty_path(raw_path)?]
    };

    Ok(DirtyEntry {
        kind: if code == "??" {
            DirtyKind::Untracked
        } else {
            DirtyKind::Modified
        },
        paths,
    })
}

fn parse_dirty_path(raw: &str) -> Result<Utf8PathBuf> {
    Utf8PathBuf::from_path_buf(std::path::PathBuf::from(raw))
        .map_err(|path| anyhow::anyhow!("non-UTF-8 git status path: {}", path.display()))
}