agentree 0.1.0

A safety-oriented local CLI for isolated Git worktree tasks
Documentation
use std::{
    fs,
    path::{Path, PathBuf},
};

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::{
    domain::{AppError, ErrorKind},
    git::{args, GitRunner},
};

#[derive(Debug, Clone)]
pub struct RepositoryFacts {
    pub root: PathBuf,
    pub common_dir: PathBuf,
    pub git_dir: PathBuf,
    pub index_path: PathBuf,
    pub branch: Option<String>,
    pub head: String,
    pub object_format: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepositoryManifest {
    pub schema_version: u32,
    pub repository_id: String,
    pub common_dir: String,
    pub state_dir: String,
    pub worktree_root: String,
    pub created_at: u64,
}

impl RepositoryManifest {
    pub fn path(&self) -> PathBuf {
        PathBuf::from(&self.common_dir).join("agentree/repository.json")
    }
}

pub fn discover(git: &GitRunner, start: &Path) -> Result<RepositoryFacts, AppError> {
    let start = if start.is_dir() {
        start
    } else {
        start.parent().unwrap_or(start)
    };
    let root = canonical(PathBuf::from(git.text(
        start,
        crate::domain::InternalGitProfile::Discovery,
        &args(&["rev-parse", "--show-toplevel"]),
    )?))?;
    let common_dir = canonical(resolve_path(
        &root,
        &git.text(
            &root,
            crate::domain::InternalGitProfile::Discovery,
            &args(&["rev-parse", "--git-common-dir"]),
        )?,
    ))?;
    let git_dir = canonical(resolve_path(
        &root,
        &git.text(
            &root,
            crate::domain::InternalGitProfile::Discovery,
            &args(&["rev-parse", "--git-dir"]),
        )?,
    ))?;
    let index_path = canonical(resolve_path(
        &root,
        &git.text(
            &root,
            crate::domain::InternalGitProfile::Discovery,
            &args(&["rev-parse", "--git-path", "index"]),
        )?,
    ))?;
    let branch = git
        .text(
            &root,
            crate::domain::InternalGitProfile::Discovery,
            &args(&["symbolic-ref", "--quiet", "--short", "HEAD"]),
        )
        .ok();
    let head = git.text(
        &root,
        crate::domain::InternalGitProfile::Discovery,
        &args(&["rev-parse", "HEAD"]),
    )?;
    let object_format = git.text(
        &root,
        crate::domain::InternalGitProfile::Discovery,
        &args(&["rev-parse", "--show-object-format"]),
    )?;
    let bare = git.text(
        &root,
        crate::domain::InternalGitProfile::Discovery,
        &args(&["rev-parse", "--is-bare-repository"]),
    )?;
    if bare == "true" {
        return Err(AppError::diagnostic(
            "AGT-0204",
            "bare repositories are not supported",
            ErrorKind::Unsupported,
        ));
    }
    Ok(RepositoryFacts {
        root,
        common_dir,
        git_dir,
        index_path,
        branch,
        head,
        object_format,
    })
}

pub fn manifest_for(facts: &RepositoryFacts) -> RepositoryManifest {
    let repository_id = repository_id(&facts.common_dir);
    let state_dir = facts.common_dir.join("agentree");
    let worktree_root = facts.common_dir.join("agentree/worktrees");
    RepositoryManifest {
        schema_version: 1,
        repository_id,
        common_dir: facts.common_dir.display().to_string(),
        state_dir: state_dir.display().to_string(),
        worktree_root: worktree_root.display().to_string(),
        created_at: now(),
    }
}

pub fn load_manifest(facts: &RepositoryFacts) -> Result<RepositoryManifest, AppError> {
    let path = facts.common_dir.join("agentree/repository.json");
    let bytes = fs::read(&path).map_err(|error| {
        AppError::diagnostic(
            "AGT-0205",
            format!("Agentree is not initialized ({error})"),
            ErrorKind::StateInconsistent,
        )
    })?;
    let manifest: RepositoryManifest = serde_json::from_slice(&bytes)?;
    if manifest.schema_version != 1
        || canonical(PathBuf::from(&manifest.common_dir))? != facts.common_dir
        || manifest.repository_id != repository_id(&facts.common_dir)
    {
        return Err(AppError::diagnostic(
            "AGT-0206",
            "repository manifest identity mismatch",
            ErrorKind::StateInconsistent,
        ));
    }
    Ok(manifest)
}

pub fn publish_manifest(manifest: &RepositoryManifest) -> Result<(), AppError> {
    let path = manifest.path();
    if path.exists() {
        let existing: RepositoryManifest = serde_json::from_slice(&fs::read(&path)?)?;
        if existing.repository_id != manifest.repository_id
            || existing.common_dir != manifest.common_dir
        {
            return Err(AppError::diagnostic(
                "AGT-0207",
                "existing manifest does not match this repository",
                ErrorKind::StateInconsistent,
            ));
        }
        return Ok(());
    }
    durable_replace(&path, &serde_json::to_vec_pretty(manifest)?)
}

pub fn durable_replace(path: &Path, bytes: &[u8]) -> Result<(), AppError> {
    let parent = path.parent().ok_or_else(|| {
        AppError::diagnostic(
            "AGT-0208",
            "manifest has no parent directory",
            ErrorKind::Io,
        )
    })?;
    fs::create_dir_all(parent)?;
    let temp = parent.join(format!(
        ".{}.tmp-{}",
        path.file_name().unwrap_or_default().to_string_lossy(),
        crate::domain::Id::new("tmp")
    ));
    {
        let mut file = std::fs::OpenOptions::new()
            .create_new(true)
            .write(true)
            .open(&temp)?;
        use std::io::Write;
        file.write_all(bytes)?;
        file.sync_all()?;
    }
    fs::rename(&temp, path)?;
    if let Ok(directory) = fs::File::open(parent) {
        let _ = directory.sync_all();
    }
    Ok(())
}

fn resolve_path(root: &Path, value: &str) -> PathBuf {
    let path = PathBuf::from(value);
    if path.is_absolute() {
        path
    } else {
        root.join(path)
    }
}
fn canonical(path: PathBuf) -> Result<PathBuf, AppError> {
    fs::canonicalize(path).map_err(AppError::from)
}
fn repository_id(common_dir: &Path) -> String {
    let mut hash = Sha256::new();
    hash.update(
        common_dir
            .to_str()
            .unwrap_or("<unsupported-path>")
            .as_bytes(),
    );
    format!("repo-{:x}", hash.finalize())
}
fn now() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or_default()
}