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:
parent
a021d8b699
commit
2fdf29d56e
10 changed files with 232 additions and 80 deletions
|
|
@ -7,29 +7,67 @@ use thiserror::Error;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
/// Blue's directory structure detection result
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BlueHome {
|
pub struct BlueHome {
|
||||||
/// Root directory containing .blue/
|
/// Root directory (git repo root) containing .blue/
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
/// Path to .blue/repos/ (markdown docs)
|
/// Path to .blue/ directory
|
||||||
pub repos_path: PathBuf,
|
pub blue_dir: PathBuf,
|
||||||
/// Path to .blue/data/ (SQLite databases)
|
/// Path to .blue/docs/
|
||||||
pub data_path: PathBuf,
|
pub docs_path: PathBuf,
|
||||||
/// Path to .blue/worktrees/ (git worktrees)
|
/// Path to .blue/blue.db
|
||||||
|
pub db_path: PathBuf,
|
||||||
|
/// Path to .blue/worktrees/
|
||||||
pub worktrees_path: PathBuf,
|
pub worktrees_path: PathBuf,
|
||||||
/// Detected project name
|
/// Detected project name (from git remote or directory name)
|
||||||
pub project_name: Option<String>,
|
pub project_name: Option<String>,
|
||||||
|
/// Whether this was migrated from old structure
|
||||||
|
pub migrated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BlueHome {
|
impl BlueHome {
|
||||||
/// Get the docs path for a specific project
|
/// Create BlueHome from a root directory
|
||||||
pub fn docs_path(&self, project: &str) -> PathBuf {
|
pub fn new(root: PathBuf) -> Self {
|
||||||
self.repos_path.join(project).join("docs")
|
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
|
/// Ensure all required directories exist
|
||||||
pub fn db_path(&self, project: &str) -> PathBuf {
|
pub fn ensure_dirs(&self) -> Result<(), std::io::Error> {
|
||||||
self.data_path.join(project).join("blue.db")
|
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
|
/// 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:
|
/// The structure is:
|
||||||
/// ```text
|
/// ```text
|
||||||
/// project/
|
/// repo/
|
||||||
/// ├── .blue/
|
/// ├── .blue/
|
||||||
/// │ ├── repos/ # Cloned repos with docs/
|
/// │ ├── docs/ # RFCs, spikes, runbooks, etc.
|
||||||
/// │ ├── data/ # SQLite databases
|
/// │ ├── worktrees/ # Git worktrees
|
||||||
/// │ └── worktrees/ # Git worktrees
|
/// │ ├── blue.db # SQLite database
|
||||||
/// └── ...
|
/// │ └── config.yaml # Configuration
|
||||||
|
/// └── src/...
|
||||||
/// ```
|
/// ```
|
||||||
pub fn detect_blue(from: &Path) -> Result<BlueHome, RepoError> {
|
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 = root.join(".blue");
|
||||||
let blue_dir = current.join(".blue");
|
|
||||||
if blue_dir.exists() && blue_dir.is_dir() {
|
|
||||||
debug!("Found Blue's home at {:?}", blue_dir);
|
|
||||||
|
|
||||||
return Ok(BlueHome {
|
// Check for new per-repo structure
|
||||||
root: current.clone(),
|
if blue_dir.exists() && blue_dir.is_dir() {
|
||||||
repos_path: blue_dir.join("repos"),
|
// Check if this is old structure that needs migration
|
||||||
data_path: blue_dir.join("data"),
|
let old_repos_path = blue_dir.join("repos");
|
||||||
worktrees_path: blue_dir.join("worktrees"),
|
let old_data_path = blue_dir.join("data");
|
||||||
project_name: extract_project_name(¤t),
|
|
||||||
});
|
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
|
debug!("Found Blue's home at {:?}", blue_dir);
|
||||||
let legacy_repos = current.join(".repos");
|
return Ok(BlueHome::new(root));
|
||||||
let legacy_data = current.join(".data");
|
}
|
||||||
if legacy_repos.exists() && legacy_data.exists() {
|
|
||||||
debug!("Found legacy Blue structure at {:?}", current);
|
|
||||||
|
|
||||||
return Ok(BlueHome {
|
// Check for legacy .repos/.data/.worktrees at root level
|
||||||
root: current.clone(),
|
let legacy_repos = root.join(".repos");
|
||||||
repos_path: legacy_repos,
|
let legacy_data = root.join(".data");
|
||||||
data_path: legacy_data,
|
if legacy_repos.exists() && legacy_data.exists() {
|
||||||
worktrees_path: current.join(".worktrees"),
|
debug!("Found legacy Blue structure at {:?}, needs migration", root);
|
||||||
project_name: extract_project_name(¤t),
|
return migrate_from_legacy_structure(&root);
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if !current.pop() {
|
// Auto-create .blue/ directory (no `blue init` required per RFC 0003)
|
||||||
break;
|
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
|
/// Extract project name from git remote or directory name
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,17 @@ impl ProjectState {
|
||||||
pub fn for_test() -> Self {
|
pub fn for_test() -> Self {
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
let store = DocumentStore::open_in_memory().unwrap();
|
let store = DocumentStore::open_in_memory().unwrap();
|
||||||
|
let root = PathBuf::from("/test");
|
||||||
|
let blue_dir = root.join(".blue");
|
||||||
Self {
|
Self {
|
||||||
home: BlueHome {
|
home: BlueHome {
|
||||||
root: PathBuf::from("/test"),
|
root: root.clone(),
|
||||||
data_path: PathBuf::from("/test/.blue/data"),
|
blue_dir: blue_dir.clone(),
|
||||||
repos_path: PathBuf::from("/test/.blue/repos"),
|
docs_path: blue_dir.join("docs"),
|
||||||
worktrees_path: PathBuf::from("/test/.blue/worktrees"),
|
db_path: blue_dir.join("blue.db"),
|
||||||
|
worktrees_path: blue_dir.join("worktrees"),
|
||||||
project_name: Some("test".to_string()),
|
project_name: Some("test".to_string()),
|
||||||
|
migrated: false,
|
||||||
},
|
},
|
||||||
store,
|
store,
|
||||||
worktrees: Vec::new(),
|
worktrees: Vec::new(),
|
||||||
|
|
@ -47,12 +51,18 @@ impl ProjectState {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load project state
|
/// 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> {
|
pub fn load(home: BlueHome, project: &str) -> Result<Self, StateError> {
|
||||||
let db_path = home.db_path(project);
|
// Ensure directories exist (auto-create per RFC 0003)
|
||||||
let store = DocumentStore::open(&db_path)?;
|
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
|
// Discover worktrees
|
||||||
let worktrees = Self::discover_worktrees(&home, project);
|
let worktrees = Self::discover_worktrees(&home);
|
||||||
let worktree_rfcs: HashSet<String> =
|
let worktree_rfcs: HashSet<String> =
|
||||||
worktrees.iter().filter_map(|wt| wt.rfc_title()).collect();
|
worktrees.iter().filter_map(|wt| wt.rfc_title()).collect();
|
||||||
|
|
||||||
|
|
@ -66,14 +76,8 @@ impl ProjectState {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discover worktrees from the repo
|
/// Discover worktrees from the repo
|
||||||
fn discover_worktrees(home: &BlueHome, project: &str) -> Vec<WorktreeInfo> {
|
fn discover_worktrees(home: &BlueHome) -> Vec<WorktreeInfo> {
|
||||||
let repo_path = home.repos_path.join(project);
|
// Try to open git repo from root
|
||||||
|
|
||||||
if let Ok(repo) = git2::Repository::open(&repo_path) {
|
|
||||||
return list_worktrees(&repo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also try from root
|
|
||||||
if let Ok(repo) = git2::Repository::discover(&home.root) {
|
if let Ok(repo) = git2::Repository::discover(&home.root) {
|
||||||
return list_worktrees(&repo);
|
return list_worktrees(&repo);
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +87,7 @@ impl ProjectState {
|
||||||
|
|
||||||
/// Reload state from disk
|
/// Reload state from disk
|
||||||
pub fn reload(&mut self) -> Result<(), StateError> {
|
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
|
self.worktree_rfcs = self
|
||||||
.worktrees
|
.worktrees
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
|
||||||
let file_path = format!("adrs/{}", file_name);
|
let file_path = format!("adrs/{}", file_name);
|
||||||
|
|
||||||
// Write the file
|
// 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);
|
let adr_path = docs_path.join(&file_path);
|
||||||
if let Some(parent) = adr_path.parent() {
|
if let Some(parent) = adr_path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
fs::create_dir_all(parent).map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
|
||||||
let file_path = format!("decisions/{}", file_name);
|
let file_path = format!("decisions/{}", file_name);
|
||||||
|
|
||||||
// Write the file
|
// 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);
|
let decision_path = docs_path.join(&file_path);
|
||||||
|
|
||||||
// Check if already exists
|
// Check if already exists
|
||||||
|
|
|
||||||
|
|
@ -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 date = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||||||
let file_name = format!("{}-{}.md", date, to_kebab_case(title));
|
let file_name = format!("{}-{}.md", date, to_kebab_case(title));
|
||||||
let file_path = PathBuf::from("postmortems").join(&file_name);
|
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);
|
let pm_path = docs_path.join(&file_path);
|
||||||
|
|
||||||
// Generate markdown content
|
// 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())
|
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_path = docs_path.join(pm_file_path);
|
||||||
let pm_content = fs::read_to_string(&pm_path)
|
let pm_content = fs::read_to_string(&pm_path)
|
||||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to read post-mortem: {}", e)))?;
|
.map_err(|e| ServerError::CommandFailed(format!("Failed to read post-mortem: {}", e)))?;
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
|
||||||
|
|
||||||
// Generate file path
|
// Generate file path
|
||||||
let file_name = format!("{:04}-{}.md", prd_number, to_kebab_case(title));
|
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
|
// Ensure directory exists
|
||||||
if let Some(parent) = file_path.parent() {
|
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
|
// Read file content
|
||||||
let rel_path = doc.file_path.as_ref()
|
let rel_path = doc.file_path.as_ref()
|
||||||
.ok_or_else(|| ServerError::CommandFailed("PRD file path not set".to_string()))?;
|
.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)
|
let content = fs::read_to_string(&file_path)
|
||||||
.map_err(|e| ServerError::CommandFailed(format!("Couldn't read PRD: {}", e)))?;
|
.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
|
// Check acceptance criteria
|
||||||
let empty_path = String::new();
|
let empty_path = String::new();
|
||||||
let rel_path = doc.file_path.as_ref().unwrap_or(&empty_path);
|
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 content = fs::read_to_string(&file_path).unwrap_or_default();
|
||||||
let criteria = parse_acceptance_criteria(&content);
|
let criteria = parse_acceptance_criteria(&content);
|
||||||
let unchecked: Vec<_> = criteria.iter().filter(|(_, c)| !c).collect();
|
let unchecked: Vec<_> = criteria.iter().filter(|(_, c)| !c).collect();
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result<Value, Se
|
||||||
// Generate file path
|
// Generate file path
|
||||||
let file_name = format!("{}.md", to_kebab_case(title));
|
let file_name = format!("{}.md", to_kebab_case(title));
|
||||||
let file_path = PathBuf::from("runbooks").join(&file_name);
|
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);
|
let runbook_path = docs_path.join(&file_path);
|
||||||
|
|
||||||
// Generate markdown content
|
// 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())
|
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 runbook_path = docs_path.join(runbook_file_path);
|
||||||
let mut content = fs::read_to_string(&runbook_path)
|
let mut content = fs::read_to_string(&runbook_path)
|
||||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to read runbook: {}", e)))?;
|
.map_err(|e| ServerError::CommandFailed(format!("Failed to read runbook: {}", e)))?;
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
|
||||||
let markdown = spike.to_markdown();
|
let markdown = spike.to_markdown();
|
||||||
|
|
||||||
// Write the file
|
// 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);
|
let spike_path = docs_path.join(&filename);
|
||||||
if let Some(parent) = spike_path.parent() {
|
if let Some(parent) = spike_path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
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
|
// Update the file if it exists
|
||||||
if let Some(ref file_path) = doc.file_path {
|
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);
|
let spike_path = docs_path.join(file_path);
|
||||||
|
|
||||||
if spike_path.exists() {
|
if spike_path.exists() {
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
|
||||||
let worktree_path = state.home.worktrees_path.join(title);
|
let worktree_path = state.home.worktrees_path.join(title);
|
||||||
|
|
||||||
// Try to create the git worktree
|
// 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) {
|
match git2::Repository::open(&repo_path) {
|
||||||
Ok(repo) => {
|
Ok(repo) => {
|
||||||
match blue_core::repo::create_worktree(&repo, &branch_name, &worktree_path) {
|
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();
|
let worktree = state.store.get_worktree(doc_id).ok().flatten();
|
||||||
|
|
||||||
// Try to open the repository
|
// 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) {
|
let repo = match git2::Repository::open(&repo_path) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -260,7 +260,7 @@ pub fn handle_remove(state: &ProjectState, args: &Value) -> Result<Value, Server
|
||||||
|
|
||||||
// Check if branch is merged (unless force)
|
// Check if branch is merged (unless force)
|
||||||
if !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) {
|
if let Ok(repo) = git2::Repository::open(&repo_path) {
|
||||||
match blue_core::repo::is_branch_merged(&repo, &worktree.branch_name, "main") {
|
match blue_core::repo::is_branch_merged(&repo, &worktree.branch_name, "main") {
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
|
|
@ -284,7 +284,7 @@ pub fn handle_remove(state: &ProjectState, args: &Value) -> Result<Value, Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from git
|
// 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 Ok(repo) = git2::Repository::open(&repo_path) {
|
||||||
if let Err(e) = blue_core::repo::remove_worktree(&repo, &worktree.branch_name) {
|
if let Err(e) = blue_core::repo::remove_worktree(&repo, &worktree.branch_name) {
|
||||||
return Ok(json!({
|
return Ok(json!({
|
||||||
|
|
|
||||||
|
|
@ -1716,7 +1716,7 @@ impl BlueServer {
|
||||||
|
|
||||||
// Generate filename and write file
|
// Generate filename and write file
|
||||||
let filename = format!("rfcs/{:04}-{}.md", number, title);
|
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);
|
let rfc_path = docs_path.join(&filename);
|
||||||
if let Some(parent) = rfc_path.parent() {
|
if let Some(parent) = rfc_path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
|
|
@ -2240,7 +2240,7 @@ impl BlueServer {
|
||||||
let empty = json!({});
|
let empty = json!({});
|
||||||
let args = args.as_ref().unwrap_or(&empty);
|
let args = args.as_ref().unwrap_or(&empty);
|
||||||
let state = self.ensure_state()?;
|
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
|
// Phase 7: Staging IaC handlers
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue