feat(core): Implement RFC 0003 per-repo .blue/ folders

Simplify Blue's directory structure to use per-repo .blue/ folders
instead of centralized ~/.blue/repos/<project>/ structure.

Changes:
- Refactor BlueHome struct with simplified per-repo paths
- Update detect_blue() to find git root and create .blue/ there
- Add migration logic from old .blue/repos/<project>/docs/ structure
- Auto-create .blue/ on first command (no 'blue init' required)
- Update all handlers to use new flat path structure
- Handle edge case: no git repo uses current directory

Structure: repo/.blue/{docs/, worktrees/, blue.db, config.yaml}

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Garcia 2026-01-24 11:41:17 -05:00
parent a021d8b699
commit 2fdf29d56e
10 changed files with 232 additions and 80 deletions

View file

@ -7,29 +7,67 @@ 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 containing .blue/
/// Root directory (git repo root) containing .blue/
pub root: PathBuf,
/// Path to .blue/repos/ (markdown docs)
pub repos_path: PathBuf,
/// Path to .blue/data/ (SQLite databases)
pub data_path: PathBuf,
/// Path to .blue/worktrees/ (git worktrees)
/// 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
/// 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 {
/// Get the docs path for a specific project
pub fn docs_path(&self, project: &str) -> PathBuf {
self.repos_path.join(project).join("docs")
/// 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,
}
}
/// Get the database path for a specific project
pub fn db_path(&self, project: &str) -> PathBuf {
self.data_path.join(project).join("blue.db")
/// 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()
}
}
@ -70,54 +108,164 @@ pub enum RepoError {
/// Detect Blue's home directory structure
///
/// Looks for .blue/ in the current directory or any parent.
/// 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
/// project/
/// repo/
/// ├── .blue/
/// │ ├── repos/ # Cloned repos with docs/
/// │ ├── data/ # SQLite databases
/// │ └── worktrees/ # Git worktrees
/// └── ...
/// │ ├── 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> {
let mut current = from.to_path_buf();
// 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()
});
loop {
let blue_dir = current.join(".blue");
if blue_dir.exists() && blue_dir.is_dir() {
debug!("Found Blue's home at {:?}", blue_dir);
let blue_dir = root.join(".blue");
return Ok(BlueHome {
root: current.clone(),
repos_path: blue_dir.join("repos"),
data_path: blue_dir.join("data"),
worktrees_path: blue_dir.join("worktrees"),
project_name: extract_project_name(&current),
});
// 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);
}
// Also check for legacy .repos/.data/.worktrees structure
let legacy_repos = current.join(".repos");
let legacy_data = current.join(".data");
if legacy_repos.exists() && legacy_data.exists() {
debug!("Found legacy Blue structure at {:?}", current);
debug!("Found Blue's home at {:?}", blue_dir);
return Ok(BlueHome::new(root));
}
return Ok(BlueHome {
root: current.clone(),
repos_path: legacy_repos,
data_path: legacy_data,
worktrees_path: current.join(".worktrees"),
project_name: extract_project_name(&current),
});
}
// 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);
}
if !current.pop() {
break;
// 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());
}
}
}
Err(RepoError::NotHome)
// 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

View file

@ -31,13 +31,17 @@ impl ProjectState {
pub fn for_test() -> Self {
use std::path::PathBuf;
let store = DocumentStore::open_in_memory().unwrap();
let root = PathBuf::from("/test");
let blue_dir = root.join(".blue");
Self {
home: BlueHome {
root: PathBuf::from("/test"),
data_path: PathBuf::from("/test/.blue/data"),
repos_path: PathBuf::from("/test/.blue/repos"),
worktrees_path: PathBuf::from("/test/.blue/worktrees"),
root: root.clone(),
blue_dir: blue_dir.clone(),
docs_path: blue_dir.join("docs"),
db_path: blue_dir.join("blue.db"),
worktrees_path: blue_dir.join("worktrees"),
project_name: Some("test".to_string()),
migrated: false,
},
store,
worktrees: Vec::new(),
@ -47,12 +51,18 @@ impl ProjectState {
}
/// Load project state
///
/// Note: `project` parameter is kept for API compatibility but is no longer
/// used for path resolution (RFC 0003 - per-repo structure)
pub fn load(home: BlueHome, project: &str) -> Result<Self, StateError> {
let db_path = home.db_path(project);
let store = DocumentStore::open(&db_path)?;
// Ensure directories exist (auto-create per RFC 0003)
home.ensure_dirs().map_err(StateError::Io)?;
// Use db_path directly from BlueHome (no project subdirectory)
let store = DocumentStore::open(&home.db_path)?;
// Discover worktrees
let worktrees = Self::discover_worktrees(&home, project);
let worktrees = Self::discover_worktrees(&home);
let worktree_rfcs: HashSet<String> =
worktrees.iter().filter_map(|wt| wt.rfc_title()).collect();
@ -66,14 +76,8 @@ impl ProjectState {
}
/// Discover worktrees from the repo
fn discover_worktrees(home: &BlueHome, project: &str) -> Vec<WorktreeInfo> {
let repo_path = home.repos_path.join(project);
if let Ok(repo) = git2::Repository::open(&repo_path) {
return list_worktrees(&repo);
}
// Also try from root
fn discover_worktrees(home: &BlueHome) -> Vec<WorktreeInfo> {
// Try to open git repo from root
if let Ok(repo) = git2::Repository::discover(&home.root) {
return list_worktrees(&repo);
}
@ -83,7 +87,7 @@ impl ProjectState {
/// Reload state from disk
pub fn reload(&mut self) -> Result<(), StateError> {
self.worktrees = Self::discover_worktrees(&self.home, &self.project);
self.worktrees = Self::discover_worktrees(&self.home);
self.worktree_rfcs = self
.worktrees
.iter()

View file

@ -78,7 +78,7 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
let file_path = format!("adrs/{}", file_name);
// Write the file
let docs_path = state.home.docs_path(&state.project);
let docs_path = state.home.docs_path.clone();
let adr_path = docs_path.join(&file_path);
if let Some(parent) = adr_path.parent() {
fs::create_dir_all(parent).map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;

View file

@ -44,7 +44,7 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
let file_path = format!("decisions/{}", file_name);
// Write the file
let docs_path = state.home.docs_path(&state.project);
let docs_path = state.home.docs_path.clone();
let decision_path = docs_path.join(&file_path);
// Check if already exists

View file

@ -83,7 +83,7 @@ pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result<Value, Se
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
let file_name = format!("{}-{}.md", date, to_kebab_case(title));
let file_path = PathBuf::from("postmortems").join(&file_name);
let docs_path = state.home.docs_path(&state.project);
let docs_path = state.home.docs_path.clone();
let pm_path = docs_path.join(&file_path);
// Generate markdown content
@ -152,7 +152,7 @@ pub fn handle_action_to_rfc(state: &mut ProjectState, args: &Value) -> Result<Va
ServerError::CommandFailed("Post-mortem has no file path".to_string())
})?;
let docs_path = state.home.docs_path(&state.project);
let docs_path = state.home.docs_path.clone();
let pm_path = docs_path.join(pm_file_path);
let pm_content = fs::read_to_string(&pm_path)
.map_err(|e| ServerError::CommandFailed(format!("Failed to read post-mortem: {}", e)))?;

View file

@ -40,7 +40,7 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
// Generate file path
let file_name = format!("{:04}-{}.md", prd_number, to_kebab_case(title));
let file_path = state.home.docs_path(&state.project).join("prds").join(&file_name);
let file_path = state.home.docs_path.join("prds").join(&file_name);
// Ensure directory exists
if let Some(parent) = file_path.parent() {
@ -90,7 +90,7 @@ pub fn handle_get(state: &ProjectState, args: &Value) -> Result<Value, ServerErr
// Read file content
let rel_path = doc.file_path.as_ref()
.ok_or_else(|| ServerError::CommandFailed("PRD file path not set".to_string()))?;
let file_path = state.home.docs_path(&state.project).join(rel_path);
let file_path = state.home.docs_path.join(rel_path);
let content = fs::read_to_string(&file_path)
.map_err(|e| ServerError::CommandFailed(format!("Couldn't read PRD: {}", e)))?;
@ -188,7 +188,7 @@ pub fn handle_complete(state: &ProjectState, args: &Value) -> Result<Value, Serv
// Check acceptance criteria
let empty_path = String::new();
let rel_path = doc.file_path.as_ref().unwrap_or(&empty_path);
let file_path = state.home.docs_path(&state.project).join(rel_path);
let file_path = state.home.docs_path.join(rel_path);
let content = fs::read_to_string(&file_path).unwrap_or_default();
let criteria = parse_acceptance_criteria(&content);
let unchecked: Vec<_> = criteria.iter().filter(|(_, c)| !c).collect();

View file

@ -54,7 +54,7 @@ pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result<Value, Se
// Generate file path
let file_name = format!("{}.md", to_kebab_case(title));
let file_path = PathBuf::from("runbooks").join(&file_name);
let docs_path = state.home.docs_path(&state.project);
let docs_path = state.home.docs_path.clone();
let runbook_path = docs_path.join(&file_path);
// Generate markdown content
@ -145,7 +145,7 @@ pub fn handle_update(state: &mut ProjectState, args: &Value) -> Result<Value, Se
ServerError::CommandFailed("Runbook has no file path".to_string())
})?;
let docs_path = state.home.docs_path(&state.project);
let docs_path = state.home.docs_path.clone();
let runbook_path = docs_path.join(runbook_file_path);
let mut content = fs::read_to_string(&runbook_path)
.map_err(|e| ServerError::CommandFailed(format!("Failed to read runbook: {}", e)))?;

View file

@ -37,7 +37,7 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
let markdown = spike.to_markdown();
// Write the file
let docs_path = state.home.docs_path(&state.project);
let docs_path = state.home.docs_path.clone();
let spike_path = docs_path.join(&filename);
if let Some(parent) = spike_path.parent() {
fs::create_dir_all(parent).map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
@ -123,7 +123,7 @@ pub fn handle_complete(state: &ProjectState, args: &Value) -> Result<Value, Serv
// Update the file if it exists
if let Some(ref file_path) = doc.file_path {
let docs_path = state.home.docs_path(&state.project);
let docs_path = state.home.docs_path.clone();
let spike_path = docs_path.join(file_path);
if spike_path.exists() {

View file

@ -49,7 +49,7 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
let worktree_path = state.home.worktrees_path.join(title);
// Try to create the git worktree
let repo_path = state.home.repos_path.join(&state.project);
let repo_path = state.home.root.clone();
match git2::Repository::open(&repo_path) {
Ok(repo) => {
match blue_core::repo::create_worktree(&repo, &branch_name, &worktree_path) {
@ -163,7 +163,7 @@ pub fn handle_cleanup(state: &ProjectState, args: &Value) -> Result<Value, Serve
let worktree = state.store.get_worktree(doc_id).ok().flatten();
// Try to open the repository
let repo_path = state.home.repos_path.join(&state.project);
let repo_path = state.home.root.clone();
let repo = match git2::Repository::open(&repo_path) {
Ok(r) => r,
Err(e) => {
@ -260,7 +260,7 @@ pub fn handle_remove(state: &ProjectState, args: &Value) -> Result<Value, Server
// Check if branch is merged (unless force)
if !force {
let repo_path = state.home.repos_path.join(&state.project);
let repo_path = state.home.root.clone();
if let Ok(repo) = git2::Repository::open(&repo_path) {
match blue_core::repo::is_branch_merged(&repo, &worktree.branch_name, "main") {
Ok(false) => {
@ -284,7 +284,7 @@ pub fn handle_remove(state: &ProjectState, args: &Value) -> Result<Value, Server
}
// Remove from git
let repo_path = state.home.repos_path.join(&state.project);
let repo_path = state.home.root.clone();
if let Ok(repo) = git2::Repository::open(&repo_path) {
if let Err(e) = blue_core::repo::remove_worktree(&repo, &worktree.branch_name) {
return Ok(json!({

View file

@ -1716,7 +1716,7 @@ impl BlueServer {
// Generate filename and write file
let filename = format!("rfcs/{:04}-{}.md", number, title);
let docs_path = state.home.docs_path(&state.project);
let docs_path = state.home.docs_path.clone();
let rfc_path = docs_path.join(&filename);
if let Some(parent) = rfc_path.parent() {
fs::create_dir_all(parent)
@ -2240,7 +2240,7 @@ impl BlueServer {
let empty = json!({});
let args = args.as_ref().unwrap_or(&empty);
let state = self.ensure_state()?;
crate::handlers::guide::handle_guide(args, &state.home.data_path)
crate::handlers::guide::handle_guide(args, &state.home.blue_dir)
}
// Phase 7: Staging IaC handlers