blue/crates/blue-core/src/repo.rs
Eric Garcia 87e0066c36 chore: apply clippy fixes and fix invalid YAML test
- Replace redundant closures with function references
- Use next_back() instead of last() for DoubleEndedIterator
- Fix test_parse_index_response_invalid to use actually invalid YAML
  (previous test string was valid YAML - a plain string with braces)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 18:35:54 -05:00

437 lines
14 KiB
Rust

//! Git repository detection and operations for Blue
//!
//! Finds Blue's home (.blue/) and manages worktrees.
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::debug;
/// Blue's directory structure detection result
///
/// Per-repo structure (RFC 0003):
/// ```text
/// repo/
/// ├── .blue/
/// │ ├── docs/ # RFCs, spikes, runbooks, etc.
/// │ ├── worktrees/ # Git worktrees for RFC implementation
/// │ ├── blue.db # SQLite database
/// │ └── config.yaml # Configuration
/// └── src/...
/// ```
#[derive(Debug, Clone)]
pub struct BlueHome {
/// Root directory (git repo root) containing .blue/
pub root: PathBuf,
/// Path to .blue/ directory
pub blue_dir: PathBuf,
/// Path to .blue/docs/
pub docs_path: PathBuf,
/// Path to .blue/blue.db
pub db_path: PathBuf,
/// Path to .blue/worktrees/
pub worktrees_path: PathBuf,
/// Detected project name (from git remote or directory name)
pub project_name: Option<String>,
/// Whether this was migrated from old structure
pub migrated: bool,
}
impl BlueHome {
/// Create BlueHome from a root directory
pub fn new(root: PathBuf) -> Self {
let blue_dir = root.join(".blue");
Self {
docs_path: blue_dir.join("docs"),
db_path: blue_dir.join("blue.db"),
worktrees_path: blue_dir.join("worktrees"),
project_name: extract_project_name(&root),
migrated: false,
blue_dir,
root,
}
}
/// Ensure all required directories exist
pub fn ensure_dirs(&self) -> Result<(), std::io::Error> {
std::fs::create_dir_all(&self.blue_dir)?;
std::fs::create_dir_all(&self.docs_path)?;
std::fs::create_dir_all(&self.worktrees_path)?;
Ok(())
}
// Legacy compatibility methods - deprecated, will be removed
#[deprecated(note = "Use docs_path field directly")]
pub fn docs_path_legacy(&self, _project: &str) -> PathBuf {
self.docs_path.clone()
}
#[deprecated(note = "Use db_path field directly")]
pub fn db_path_legacy(&self, _project: &str) -> PathBuf {
self.db_path.clone()
}
}
/// Information about a git worktree
#[derive(Debug, Clone)]
pub struct WorktreeInfo {
/// Path to the worktree
pub path: PathBuf,
/// Branch name
pub branch: String,
/// Whether this is the main worktree
pub is_main: bool,
}
impl WorktreeInfo {
/// Extract RFC title from branch name if it follows the pattern rfc/{title}
pub fn rfc_title(&self) -> Option<String> {
if self.branch.starts_with("rfc/") {
Some(self.branch.trim_start_matches("rfc/").to_string())
} else {
None
}
}
}
/// Repository errors
#[derive(Debug, Error)]
pub enum RepoError {
#[error("Can't find Blue here. Run 'blue init' first?")]
NotHome,
#[error("Git trouble: {0}")]
Git(#[from] git2::Error),
#[error("Can't read directory: {0}")]
Io(#[from] std::io::Error),
}
/// Detect Blue's home directory structure
///
/// RFC 0003: Per-repo .blue/ folders
/// - Finds git repo root for current directory
/// - Creates .blue/ there if it doesn't exist (auto-init)
/// - Migrates from old structure if needed
///
/// The structure is:
/// ```text
/// repo/
/// ├── .blue/
/// │ ├── docs/ # RFCs, spikes, runbooks, etc.
/// │ ├── worktrees/ # Git worktrees
/// │ ├── blue.db # SQLite database
/// │ └── config.yaml # Configuration
/// └── src/...
/// ```
pub fn detect_blue(from: &Path) -> Result<BlueHome, RepoError> {
// First, try to find git repo root
let root = find_git_root(from).unwrap_or_else(|| {
debug!("No git repo found, using current directory");
from.to_path_buf()
});
let blue_dir = root.join(".blue");
// Check for new per-repo structure
if blue_dir.exists() && blue_dir.is_dir() {
// Check if this is old structure that needs migration
let old_repos_path = blue_dir.join("repos");
let old_data_path = blue_dir.join("data");
if old_repos_path.exists() || old_data_path.exists() {
debug!("Found old Blue structure at {:?}, needs migration", blue_dir);
return migrate_to_new_structure(&root);
}
debug!("Found Blue's home at {:?}", blue_dir);
return Ok(BlueHome::new(root));
}
// Check for legacy .repos/.data/.worktrees at root level
let legacy_repos = root.join(".repos");
let legacy_data = root.join(".data");
if legacy_repos.exists() && legacy_data.exists() {
debug!("Found legacy Blue structure at {:?}, needs migration", root);
return migrate_from_legacy_structure(&root);
}
// Auto-create .blue/ directory (no `blue init` required per RFC 0003)
debug!("Creating new Blue home at {:?}", blue_dir);
let home = BlueHome::new(root);
home.ensure_dirs().map_err(RepoError::Io)?;
Ok(home)
}
/// Find the git repository root from a given path
fn find_git_root(from: &Path) -> Option<PathBuf> {
git2::Repository::discover(from)
.ok()
.and_then(|repo| repo.workdir().map(|p| p.to_path_buf()))
}
/// Migrate from old .blue/repos/<project>/docs structure to new .blue/docs structure
fn migrate_to_new_structure(root: &Path) -> Result<BlueHome, RepoError> {
let blue_dir = root.join(".blue");
let old_repos_path = blue_dir.join("repos");
let old_data_path = blue_dir.join("data");
let new_docs_path = blue_dir.join("docs");
let new_db_path = blue_dir.join("blue.db");
// Get project name to find the right subdirectory
let project_name = extract_project_name(root)
.unwrap_or_else(|| "default".to_string());
// Migrate docs: .blue/repos/<project>/docs -> .blue/docs
let old_project_docs = old_repos_path.join(&project_name).join("docs");
if old_project_docs.exists() && !new_docs_path.exists() {
debug!("Migrating docs from {:?} to {:?}", old_project_docs, new_docs_path);
std::fs::rename(&old_project_docs, &new_docs_path)
.map_err(RepoError::Io)?;
}
// Migrate database: .blue/data/<project>/blue.db -> .blue/blue.db
let old_project_db = old_data_path.join(&project_name).join("blue.db");
if old_project_db.exists() && !new_db_path.exists() {
debug!("Migrating database from {:?} to {:?}", old_project_db, new_db_path);
std::fs::rename(&old_project_db, &new_db_path)
.map_err(RepoError::Io)?;
}
// Clean up empty old directories
cleanup_empty_dirs(&old_repos_path);
cleanup_empty_dirs(&old_data_path);
let mut home = BlueHome::new(root.to_path_buf());
home.migrated = true;
home.ensure_dirs().map_err(RepoError::Io)?;
debug!("Migration complete for {:?}", root);
Ok(home)
}
/// Migrate from legacy .repos/.data structure at root level
fn migrate_from_legacy_structure(root: &Path) -> Result<BlueHome, RepoError> {
let legacy_repos = root.join(".repos");
let legacy_data = root.join(".data");
let blue_dir = root.join(".blue");
// Create new .blue directory
std::fs::create_dir_all(&blue_dir).map_err(RepoError::Io)?;
let project_name = extract_project_name(root)
.unwrap_or_else(|| "default".to_string());
// Migrate docs
let old_docs = legacy_repos.join(&project_name).join("docs");
let new_docs = blue_dir.join("docs");
if old_docs.exists() && !new_docs.exists() {
debug!("Migrating legacy docs from {:?} to {:?}", old_docs, new_docs);
std::fs::rename(&old_docs, &new_docs).map_err(RepoError::Io)?;
}
// Migrate database
let old_db = legacy_data.join(&project_name).join("blue.db");
let new_db = blue_dir.join("blue.db");
if old_db.exists() && !new_db.exists() {
debug!("Migrating legacy database from {:?} to {:?}", old_db, new_db);
std::fs::rename(&old_db, &new_db).map_err(RepoError::Io)?;
}
// Clean up old directories
cleanup_empty_dirs(&legacy_repos);
cleanup_empty_dirs(&legacy_data);
let mut home = BlueHome::new(root.to_path_buf());
home.migrated = true;
home.ensure_dirs().map_err(RepoError::Io)?;
debug!("Legacy migration complete for {:?}", root);
Ok(home)
}
/// Recursively remove empty directories
fn cleanup_empty_dirs(path: &Path) {
if !path.exists() || !path.is_dir() {
return;
}
// Try to remove subdirectories first
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
if entry.path().is_dir() {
cleanup_empty_dirs(&entry.path());
}
}
}
// Try to remove this directory (will fail if not empty, which is fine)
let _ = std::fs::remove_dir(path);
}
/// Extract project name from git remote or directory name
fn extract_project_name(path: &Path) -> Option<String> {
// Try git remote first
if let Ok(repo) = git2::Repository::discover(path) {
if let Ok(remote) = repo.find_remote("origin") {
if let Some(url) = remote.url() {
return extract_repo_name_from_url(url);
}
}
}
// Fall back to directory name
path.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
}
/// Extract repository name from a git URL
fn extract_repo_name_from_url(url: &str) -> Option<String> {
// Handle SSH URLs: git@host:org/repo.git
if url.contains(':') && !url.contains("://") {
let after_colon = url.split(':').next_back()?;
let name = after_colon.trim_end_matches(".git");
return name.split('/').next_back().map(|s| s.to_string());
}
// Handle HTTPS URLs: https://host/org/repo.git
let name = url.trim_end_matches(".git");
name.split('/').next_back().map(|s| s.to_string())
}
/// List git worktrees for a repository
pub fn list_worktrees(repo: &git2::Repository) -> Vec<WorktreeInfo> {
let mut worktrees = Vec::new();
// Add main worktree
if let Some(workdir) = repo.workdir() {
if let Ok(head) = repo.head() {
let branch = head
.shorthand()
.map(|s| s.to_string())
.unwrap_or_else(|| "HEAD".to_string());
worktrees.push(WorktreeInfo {
path: workdir.to_path_buf(),
branch,
is_main: true,
});
}
}
// Add other worktrees
if let Ok(wt_names) = repo.worktrees() {
for name in wt_names.iter().flatten() {
if let Ok(wt) = repo.find_worktree(name) {
if let Some(path) = wt.path().to_str() {
// Try to get the branch for this worktree
let branch = wt.name().unwrap_or("unknown").to_string();
worktrees.push(WorktreeInfo {
path: PathBuf::from(path),
branch,
is_main: false,
});
}
}
}
}
worktrees
}
/// Create a new worktree for an RFC
pub fn create_worktree(
repo: &git2::Repository,
branch_name: &str,
worktree_path: &Path,
) -> Result<(), RepoError> {
// Create the branch if it doesn't exist
let head = repo.head()?;
let head_commit = head.peel_to_commit()?;
let branch = match repo.find_branch(branch_name, git2::BranchType::Local) {
Ok(branch) => branch,
Err(_) => repo.branch(branch_name, &head_commit, false)?,
};
// Create the worktree
let reference = branch.into_reference();
repo.worktree(
branch_name,
worktree_path,
Some(git2::WorktreeAddOptions::new().reference(Some(&reference))),
)?;
Ok(())
}
/// Remove a worktree
pub fn remove_worktree(repo: &git2::Repository, name: &str) -> Result<(), RepoError> {
let worktree = repo.find_worktree(name)?;
// Prune the worktree (this removes the worktree but keeps the branch)
worktree.prune(Some(
git2::WorktreePruneOptions::new()
.valid(true)
.working_tree(true),
))?;
Ok(())
}
/// Check if a branch is merged into another
pub fn is_branch_merged(
repo: &git2::Repository,
branch: &str,
into: &str,
) -> Result<bool, RepoError> {
let branch_commit = repo
.find_branch(branch, git2::BranchType::Local)?
.get()
.peel_to_commit()?
.id();
let into_commit = repo
.find_branch(into, git2::BranchType::Local)?
.get()
.peel_to_commit()?
.id();
// Check if branch_commit is an ancestor of into_commit
Ok(repo.graph_descendant_of(into_commit, branch_commit)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_repo_name_ssh() {
let url = "git@github.com:superviber/blue.git";
assert_eq!(extract_repo_name_from_url(url), Some("blue".to_string()));
}
#[test]
fn test_extract_repo_name_https() {
let url = "https://github.com/superviber/blue.git";
assert_eq!(extract_repo_name_from_url(url), Some("blue".to_string()));
}
#[test]
fn test_worktree_info_rfc_title() {
let wt = WorktreeInfo {
path: PathBuf::from("/tmp/test"),
branch: "rfc/my-feature".to_string(),
is_main: false,
};
assert_eq!(wt.rfc_title(), Some("my-feature".to_string()));
let main = WorktreeInfo {
path: PathBuf::from("/tmp/main"),
branch: "main".to_string(),
is_main: true,
};
assert_eq!(main.rfc_title(), None);
}
}