rusty-beads 0.1.0

Git-backed graph issue tracker for AI coding agents - a Rust implementation with context store, dependency tracking, and semantic compaction
Documentation
//! Git integration for Beads.
//!
//! Provides functionality for finding the .beads directory,
//! committing changes, and syncing with remote repositories.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use git2::{Repository, Signature};

/// Name of the beads directory.
pub const BEADS_DIR: &str = ".beads";

/// Name of the database file.
pub const DB_FILE: &str = "beads.db";

/// Name of the JSONL file.
pub const JSONL_FILE: &str = "issues.jsonl";

/// Git repository wrapper for Beads operations.
pub struct GitRepo {
    repo: Repository,
}

impl GitRepo {
    /// Open an existing git repository.
    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
        let repo = Repository::open(path.as_ref())
            .with_context(|| format!("Failed to open git repository at {:?}", path.as_ref()))?;
        Ok(Self { repo })
    }

    /// Discover and open the git repository from the current directory.
    pub fn discover() -> Result<Self> {
        let repo = Repository::discover(".")
            .context("Failed to discover git repository")?;
        Ok(Self { repo })
    }

    /// Get the repository's working directory.
    pub fn workdir(&self) -> Option<&Path> {
        self.repo.workdir()
    }

    /// Find the .beads directory in the repository.
    pub fn find_beads_dir(&self) -> Option<PathBuf> {
        let workdir = self.workdir()?;
        let beads_path = workdir.join(BEADS_DIR);
        if beads_path.exists() {
            Some(beads_path)
        } else {
            None
        }
    }

    /// Get the database path.
    pub fn database_path(&self) -> Option<PathBuf> {
        self.find_beads_dir().map(|p| p.join(DB_FILE))
    }

    /// Get the JSONL file path.
    pub fn jsonl_path(&self) -> Option<PathBuf> {
        self.find_beads_dir().map(|p| p.join(JSONL_FILE))
    }

    /// Get the current branch name.
    pub fn current_branch(&self) -> Result<String> {
        let head = self.repo.head()
            .context("Failed to get HEAD")?;
        let name = head.shorthand()
            .unwrap_or("HEAD")
            .to_string();
        Ok(name)
    }

    /// Check if there are uncommitted changes in the .beads directory.
    pub fn has_beads_changes(&self) -> Result<bool> {
        let statuses = self.repo.statuses(None)
            .context("Failed to get repository status")?;

        for status in statuses.iter() {
            if let Some(path) = status.path() {
                if path.starts_with(BEADS_DIR) {
                    return Ok(true);
                }
            }
        }

        Ok(false)
    }

    /// Stage files in the .beads directory.
    pub fn stage_beads_files(&self) -> Result<()> {
        let mut index = self.repo.index()
            .context("Failed to get index")?;

        // Add all files in .beads directory
        index.add_all([BEADS_DIR], git2::IndexAddOption::DEFAULT, None)
            .context("Failed to stage .beads files")?;

        index.write()
            .context("Failed to write index")?;

        Ok(())
    }

    /// Commit staged changes.
    pub fn commit(&self, message: &str, author_name: &str, author_email: &str) -> Result<git2::Oid> {
        let mut index = self.repo.index()
            .context("Failed to get index")?;

        let tree_id = index.write_tree()
            .context("Failed to write tree")?;

        let tree = self.repo.find_tree(tree_id)
            .context("Failed to find tree")?;

        let signature = Signature::now(author_name, author_email)
            .context("Failed to create signature")?;

        let head = self.repo.head()
            .context("Failed to get HEAD")?;

        let parent = self.repo.find_commit(head.target().unwrap())
            .context("Failed to find parent commit")?;

        let oid = self.repo.commit(
            Some("HEAD"),
            &signature,
            &signature,
            message,
            &tree,
            &[&parent],
        ).context("Failed to create commit")?;

        Ok(oid)
    }

    /// Commit changes with the default beads signature.
    pub fn commit_beads_changes(&self, message: &str, actor: &str) -> Result<git2::Oid> {
        self.stage_beads_files()?;

        // Try to get git config for email, fall back to a default
        let email = self.repo.config()
            .ok()
            .and_then(|cfg| cfg.get_string("user.email").ok())
            .unwrap_or_else(|| format!("{}@beads", actor));

        self.commit(message, actor, &email)
    }

    /// Get the user name from git config.
    pub fn get_user_name(&self) -> Option<String> {
        self.repo.config()
            .ok()
            .and_then(|cfg| cfg.get_string("user.name").ok())
    }

    /// Get the user email from git config.
    pub fn get_user_email(&self) -> Option<String> {
        self.repo.config()
            .ok()
            .and_then(|cfg| cfg.get_string("user.email").ok())
    }
}

/// Find the .beads directory by searching up the directory tree.
pub fn find_beads_dir() -> Option<PathBuf> {
    let mut current = std::env::current_dir().ok()?;

    loop {
        let beads_path = current.join(BEADS_DIR);
        if beads_path.exists() && beads_path.is_dir() {
            return Some(beads_path);
        }

        if !current.pop() {
            return None;
        }
    }
}

/// Find the database path by searching for .beads directory.
pub fn find_database_path() -> Option<PathBuf> {
    find_beads_dir().map(|p| p.join(DB_FILE))
}

/// Find the JSONL file path by searching for .beads directory.
pub fn find_jsonl_path() -> Option<PathBuf> {
    find_beads_dir().map(|p| p.join(JSONL_FILE))
}

/// Initialize the .beads directory.
pub fn init_beads_dir(path: impl AsRef<Path>) -> Result<PathBuf> {
    let beads_path = path.as_ref().join(BEADS_DIR);
    std::fs::create_dir_all(&beads_path)
        .with_context(|| format!("Failed to create .beads directory at {:?}", beads_path))?;
    Ok(beads_path)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn test_find_beads_dir_in_current() {
        let temp = TempDir::new().unwrap();
        let beads_path = temp.path().join(BEADS_DIR);
        std::fs::create_dir(&beads_path).unwrap();

        // Change to temp directory and verify we find it
        let old_dir = std::env::current_dir().unwrap();
        std::env::set_current_dir(temp.path()).unwrap();

        let found = find_beads_dir();
        assert!(found.is_some());
        // Compare file names only to avoid macOS /private symlink issues
        assert!(found.unwrap().ends_with(BEADS_DIR));

        std::env::set_current_dir(old_dir).unwrap();
    }

    #[test]
    fn test_init_beads_dir() {
        let temp = TempDir::new().unwrap();
        let result = init_beads_dir(temp.path());

        assert!(result.is_ok());
        assert!(temp.path().join(BEADS_DIR).exists());
    }
}