diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 15ead4958..b064dc8c2 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -197,6 +197,7 @@ helix-cli/ - `helix add` - Add dependencies to project - `helix auth` - Authentication management (login/logout/create-key) - `helix build` - Build queries without deploying +- `helix branch` - Branch a local instance database - `helix check` - Validate schema and query syntax - `helix compile` - Compile queries to Rust code - `helix delete` - Remove instance and data diff --git a/Cargo.lock b/Cargo.lock index e22f02806..4b9205bc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1101,6 +1101,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.31" @@ -1384,6 +1394,7 @@ dependencies = [ "dotenvy", "eyre", "flume", + "fs2", "futures-util", "heed3", "helix-db", diff --git a/helix-cli/Cargo.toml b/helix-cli/Cargo.toml index 31bb98276..56e0fed7b 100644 --- a/helix-cli/Cargo.toml +++ b/helix-cli/Cargo.toml @@ -31,6 +31,7 @@ indicatif = "0.18.3" webbrowser = "1.0" heed3 = "0.22.0" open = "5.3" +fs2 = "0.4.3" [dev-dependencies] tempfile = "3.23.0" diff --git a/helix-cli/README.md b/helix-cli/README.md index e1ef0843f..ceafa857f 100644 --- a/helix-cli/README.md +++ b/helix-cli/README.md @@ -15,6 +15,11 @@ push - (--force/--f) pull - () +branch + - () + - (--output/--o) + - (--name) + - (--port) init - (--template/--t): template to use e.g. ts, py, fli.io, docker etc. - (--alias/--a): name to call cluster diff --git a/helix-cli/src/commands/add.rs b/helix-cli/src/commands/add.rs index 42052254c..c352528ca 100644 --- a/helix-cli/src/commands/add.rs +++ b/helix-cli/src/commands/add.rs @@ -239,6 +239,7 @@ async fn run_add_inner( let local_config = LocalInstanceConfig { port: None, // Let the system assign a port build_mode: BuildMode::Debug, + data_dir: None, db_config: DbConfig::default(), }; diff --git a/helix-cli/src/commands/backup.rs b/helix-cli/src/commands/backup.rs index f87ffa9dc..20f6a0c68 100644 --- a/helix-cli/src/commands/backup.rs +++ b/helix-cli/src/commands/backup.rs @@ -11,22 +11,50 @@ pub async fn run(output: Option, instance_name: String) -> Result<()> { // Load project context let project = ProjectContext::find_and_load(None)?; - // Get instance config - let _instance_config = project.config.get_instance(&instance_name)?; - print_status("BACKUP", &format!("Backing up instance '{instance_name}'")); - // Get the instance volume - let volumes_dir = project - .root - .join(".helix") - .join(".volumes") - .join(&instance_name) - .join("user"); + // Get path to backup instance + let backup_dir = match output { + Some(path) => path, + None => { + let ts = chrono::Local::now() + .format("backup-%Y%m%d-%H%M%S") + .to_string(); + project.root.join("backups").join(ts) + } + }; + + let completed = backup_instance_to_dir(&project, &instance_name, &backup_dir)?; + if !completed { + return Ok(()); + } + + print_success(&format!( + "Backup for '{instance_name}' created at {:?}", + backup_dir + )); - let data_file = volumes_dir.join("data.mdb"); + Ok(()) +} - let env_path = Path::new(&volumes_dir); +pub(crate) fn backup_instance_to_dir( + project: &ProjectContext, + instance_name: &str, + output_dir: &Path, +) -> Result { + // Get instance config + let instance_config = project.config.get_instance(instance_name)?; + + if !instance_config.is_local() { + return Err(eyre::eyre!( + "Backup is only supported for local instances" + )); + } + + // Get the instance volume + let env_path = project.instance_user_dir(instance_name)?; + let data_file = project.instance_data_file(instance_name)?; + let env_path = Path::new(&env_path); // Validate existence of environment if !env_path.exists() { @@ -44,18 +72,7 @@ pub async fn run(output: Option, instance_name: String) -> Result<()> { )); } - // Get path to backup instance - let backup_dir = match output { - Some(path) => path, - None => { - let ts = chrono::Local::now() - .format("backup-%Y%m%d-%H%M%S") - .to_string(); - project.root.join("backups").join(ts) - } - }; - - create_dir_all(&backup_dir)?; + create_dir_all(output_dir)?; // Get the size of the data let total_size = fs::metadata(&data_file)?.len(); @@ -63,7 +80,6 @@ pub async fn run(output: Option, instance_name: String) -> Result<()> { const TEN_GB: u64 = 10 * 1024 * 1024 * 1024; // Check and warn if file is greater than 10 GB - if total_size > TEN_GB { let size_gb = (total_size as f64) / (1024.0 * 1024.0 * 1024.0); print_warning(&format!( @@ -73,7 +89,7 @@ pub async fn run(output: Option, instance_name: String) -> Result<()> { let confirmed = print_confirm("Do you want to continue?"); if !confirmed? { print_status("CANCEL", "Backup aborted by user"); - return Ok(()); + return Ok(false); } } @@ -86,15 +102,10 @@ pub async fn run(output: Option, instance_name: String) -> Result<()> { .open(env_path)? }; - println!("Copying {:?} → {:?}", &data_file, &backup_dir); + println!("Copying {:?} → {:?}", &data_file, &output_dir); // backup database to given database - env.copy_to_path(backup_dir.join("data.mdb"), CompactionOption::Disabled)?; + env.copy_to_path(output_dir.join("data.mdb"), CompactionOption::Disabled)?; - print_success(&format!( - "Backup for '{instance_name}' created at {:?}", - backup_dir - )); - - Ok(()) + Ok(true) } diff --git a/helix-cli/src/commands/branch.rs b/helix-cli/src/commands/branch.rs new file mode 100644 index 000000000..4602b61a2 --- /dev/null +++ b/helix-cli/src/commands/branch.rs @@ -0,0 +1,285 @@ +use crate::commands::{backup::backup_instance_to_dir, build}; +use crate::config::{InstanceInfo, LocalInstanceConfig}; +use crate::docker::DockerManager; +use crate::metrics_sender::MetricsSender; +use crate::project::ProjectContext; +use crate::prompts; +use crate::utils::{print_confirm, print_status, print_success, print_warning}; +use eyre::{Result, eyre}; +use std::fs; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; + +pub async fn run( + instance: String, + output: Option, + name: Option, + port: Option, + metrics_sender: &MetricsSender, +) -> Result<()> { + let mut project = ProjectContext::find_and_load(None)?; + let instance_config = project.config.get_instance(&instance)?; + let source_config = match instance_config { + InstanceInfo::Local(config) => config, + _ => { + return Err(eyre!("Branch is only supported for local instances")); + } + }; + + let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(); + let branch_name = name.unwrap_or_else(|| format!("{instance}-branch-{timestamp}")); + ensure_branch_name_available(&project, &branch_name)?; + let output_dir = resolve_output_dir(&project, output, &branch_name); + let output_user_dir = output_dir.join("user"); + + print_status( + "BRANCH", + &format!( + "Creating new local instance from '{instance}' at {}", + output_dir.display() + ), + ); + + prepare_output_user_dir(&output_user_dir)?; + let backup_completed = backup_instance_to_dir(&project, &instance, &output_user_dir)?; + if !backup_completed { + return Ok(()); + } + + print_success(&format!( + "New local instance data created at {}", + output_dir.display() + )); + + let port = resolve_branch_port(port)?; + + let branch_config = LocalInstanceConfig { + port: Some(port), + build_mode: source_config.build_mode, + data_dir: Some(output_dir.clone()), + db_config: source_config.db_config.clone(), + }; + + persist_branch_config(&mut project, &branch_name, branch_config)?; + + print_status( + "DEPLOY", + &format!("Deploying branched instance '{branch_name}'"), + ); + + DockerManager::check_runtime_available(project.config.project.container_runtime)?; + build::run(Some(branch_name.clone()), metrics_sender).await?; + + let docker = DockerManager::new(&project); + docker.start_instance(&branch_name)?; + + print_success(&format!( + "Branched local instance '{branch_name}' is now running" + )); + println!(" Local URL: http://localhost:{port}"); + let project_name = &project.config.project.name; + println!(" Container: helix_{project_name}_{branch_name}"); + println!(" Data volume: {}", output_dir.display()); + + Ok(()) +} + +pub(crate) fn resolve_output_dir( + project: &ProjectContext, + output: Option, + branch_name: &str, +) -> PathBuf { + match output { + Some(path) => { + if path.is_absolute() { + path + } else { + project.root.join(path) + } + } + None => project.helix_dir.join(".volumes").join(branch_name), + } +} + +pub(crate) fn prepare_output_user_dir(output_user_dir: &Path) -> Result<()> { + if output_user_dir.exists() { + let data_path = output_user_dir.join("data.mdb"); + let has_entries = data_path.exists() || fs::read_dir(output_user_dir)?.next().is_some(); + if has_entries { + if prompts::is_interactive() { + print_warning(&format!( + "Output directory already exists at {}", + output_user_dir.display() + )); + let confirmed = print_confirm("Overwrite existing branch output directory?")?; + if !confirmed { + return Err(eyre!( + "Output directory already exists at {}", + output_user_dir.display() + )); + } + fs::remove_dir_all(output_user_dir)?; + } else { + return Err(eyre!( + "Output directory already exists at {}", + output_user_dir.display() + )); + } + } + } + + fs::create_dir_all(output_user_dir)?; + Ok(()) +} + +pub(crate) fn persist_branch_config( + project: &mut ProjectContext, + branch_name: &str, + branch_config: LocalInstanceConfig, +) -> Result<()> { + if project.config.local.contains_key(branch_name) + || project.config.cloud.contains_key(branch_name) + { + return Err(eyre!("Instance '{branch_name}' already exists")); + } + + project + .config + .local + .insert(branch_name.to_string(), branch_config); + let config_path = project.root.join("helix.toml"); + project.config.save_to_file(&config_path)?; + Ok(()) +} + +fn select_available_port() -> Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + Ok(port) +} + +fn resolve_branch_port(port: Option) -> Result { + match port { + Some(port) => { + ensure_port_available(port)?; + Ok(port) + } + None => select_available_port(), + } +} + +fn ensure_port_available(port: u16) -> Result<()> { + TcpListener::bind(("127.0.0.1", port)).map_err(|err| { + eyre!( + "Port {port} is not available for the branched instance: {err}" + ) + })?; + Ok(()) +} + +fn ensure_branch_name_available(project: &ProjectContext, branch_name: &str) -> Result<()> { + if project.config.local.contains_key(branch_name) + || project.config.cloud.contains_key(branch_name) + { + return Err(eyre!("Instance '{branch_name}' already exists")); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ensure_branch_name_available, ensure_port_available}; + use crate::config::{ + BuildMode, CloudConfig, CloudInstanceConfig, DbConfig, HelixConfig, LocalInstanceConfig, + }; + use crate::project::ProjectContext; + use std::collections::HashMap; + use std::fs; + use std::net::TcpListener; + use tempfile::TempDir; + + fn setup_test_project() -> (TempDir, ProjectContext) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let project_path = temp_dir.path().to_path_buf(); + + let config = HelixConfig::default_config("test-project"); + let config_path = project_path.join("helix.toml"); + config + .save_to_file(&config_path) + .expect("Failed to save config"); + + fs::create_dir_all(project_path.join(".helix")).expect("Failed to create .helix"); + + let context = ProjectContext::find_and_load(Some(&project_path)) + .expect("Failed to load project context"); + + (temp_dir, context) + } + + #[test] + fn test_ensure_port_available_rejects_bound_port() { + let listener = + TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port"); + let port = listener + .local_addr() + .expect("Failed to read local addr") + .port(); + + let result = ensure_port_available(port); + assert!(result.is_err(), "Expected port to be unavailable"); + } + + #[test] + fn test_ensure_port_available_allows_free_port() { + let listener = + TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port"); + let port = listener + .local_addr() + .expect("Failed to read local addr") + .port(); + drop(listener); + + let result = ensure_port_available(port); + assert!(result.is_ok(), "Expected port to be available"); + } + + #[test] + fn test_ensure_branch_name_available_rejects_existing() { + let (_temp_dir, mut project) = setup_test_project(); + + project.config.local.insert( + "existing-local".to_string(), + LocalInstanceConfig { + port: Some(7777), + build_mode: BuildMode::Debug, + data_dir: None, + db_config: DbConfig::default(), + }, + ); + + project.config.cloud.insert( + "existing-cloud".to_string(), + CloudConfig::Helix(CloudInstanceConfig { + cluster_id: "cluster".to_string(), + region: None, + build_mode: BuildMode::Debug, + env_vars: HashMap::new(), + db_config: DbConfig::default(), + }), + ); + + let local_result = ensure_branch_name_available(&project, "existing-local"); + assert!(local_result.is_err(), "Expected local name to be rejected"); + + let cloud_result = ensure_branch_name_available(&project, "existing-cloud"); + assert!(cloud_result.is_err(), "Expected cloud name to be rejected"); + } + + #[test] + fn test_ensure_branch_name_available_allows_unique() { + let (_temp_dir, project) = setup_test_project(); + let result = ensure_branch_name_available(&project, "unique-branch"); + assert!(result.is_ok(), "Expected branch name to be available"); + } +} diff --git a/helix-cli/src/commands/build.rs b/helix-cli/src/commands/build.rs index 5f29ce920..18014ea32 100644 --- a/helix-cli/src/commands/build.rs +++ b/helix-cli/src/commands/build.rs @@ -10,6 +10,8 @@ use crate::utils::{ print_confirm, print_error, print_status, print_success, print_warning, }; use eyre::Result; +use fs2::FileExt; +use std::fs::{File, OpenOptions}; use std::time::Instant; #[derive(Debug, Clone)] @@ -28,12 +30,37 @@ use helix_db::{ }, }, }; +use std::sync::{Mutex, OnceLock}; use std::{fmt::Write, fs}; // Development flag - set to true when working on V2 locally const DEV_MODE: bool = cfg!(debug_assertions); const HELIX_REPO_URL: &str = "https://github.com/helixdb/helix-db.git"; +static REPO_CACHE_LOCK: OnceLock> = OnceLock::new(); + +fn repo_cache_lock() -> std::sync::MutexGuard<'static, ()> { + REPO_CACHE_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("repo cache lock poisoned") +} + +fn repo_cache_file_lock(repo_cache: &std::path::Path) -> Result { + let lock_path = repo_cache + .parent() + .ok_or_else(|| eyre::eyre!("Cannot determine repo cache parent"))? + .join("repo.lock"); + let lock_file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(lock_path)?; + lock_file.lock_exclusive()?; + Ok(lock_file) +} + // Get the cargo workspace root at compile time const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); @@ -78,10 +105,10 @@ pub async fn run( print_status("BUILD", &format!("Building instance '{instance_name}'")); // Ensure Helix repo is cached - ensure_helix_repo_cached().await?; + ensure_helix_repo_cached()?; // Prepare instance workspace - prepare_instance_workspace(&project, &instance_name).await?; + prepare_instance_workspace(&project, &instance_name)?; // Compile project queries into the workspace let compile_result = compile_project(&project, &instance_name).await; @@ -147,15 +174,17 @@ pub async fn run( Ok(metrics_data.clone()) } -pub(crate) async fn ensure_helix_repo_cached() -> Result<()> { +pub(crate) fn ensure_helix_repo_cached() -> Result<()> { + let _guard = repo_cache_lock(); let repo_cache = get_helix_repo_cache()?; + let _file_lock = repo_cache_file_lock(&repo_cache)?; if needs_cache_recreation(&repo_cache)? { - recreate_helix_cache(&repo_cache).await?; + recreate_helix_cache(&repo_cache)?; } else if repo_cache.exists() { - update_helix_cache(&repo_cache).await?; + update_helix_cache(&repo_cache)?; } else { - create_helix_cache(&repo_cache).await?; + create_helix_cache(&repo_cache)?; } Ok(()) @@ -187,12 +216,12 @@ fn needs_cache_recreation(repo_cache: &std::path::Path) -> Result { } } -async fn recreate_helix_cache(repo_cache: &std::path::Path) -> Result<()> { +fn recreate_helix_cache(repo_cache: &std::path::Path) -> Result<()> { std::fs::remove_dir_all(repo_cache)?; - create_helix_cache(repo_cache).await + create_helix_cache(repo_cache) } -async fn create_helix_cache(repo_cache: &std::path::Path) -> Result<()> { +fn create_helix_cache(repo_cache: &std::path::Path) -> Result<()> { print_status("CACHE", "Caching Helix repository (first time setup)..."); if DEV_MODE { @@ -205,7 +234,7 @@ async fn create_helix_cache(repo_cache: &std::path::Path) -> Result<()> { Ok(()) } -async fn update_helix_cache(repo_cache: &std::path::Path) -> Result<()> { +fn update_helix_cache(repo_cache: &std::path::Path) -> Result<()> { print_status("UPDATE", "Updating Helix repository cache..."); if DEV_MODE { @@ -272,10 +301,11 @@ fn update_git_cache(repo_cache: &std::path::Path) -> Result<()> { Ok(()) } -pub(crate) async fn prepare_instance_workspace( +pub(crate) fn prepare_instance_workspace( project: &ProjectContext, instance_name: &str, ) -> Result<()> { + let _guard = repo_cache_lock(); print_status( "PREPARE", &format!("Preparing workspace for '{instance_name}'"), @@ -286,6 +316,7 @@ pub(crate) async fn prepare_instance_workspace( // Copy cached repo to instance workspace for Docker build context let repo_cache = get_helix_repo_cache()?; + let _file_lock = repo_cache_file_lock(&repo_cache)?; let instance_workspace = project.instance_workspace(instance_name); let repo_copy_path = instance_workspace.join("helix-repo-copy"); diff --git a/helix-cli/src/commands/check.rs b/helix-cli/src/commands/check.rs index 97ae5f936..6a375f9ab 100644 --- a/helix-cli/src/commands/check.rs +++ b/helix-cli/src/commands/check.rs @@ -50,10 +50,10 @@ async fn check_instance( print_success("Syntax validation passed"); // Step 2: Ensure helix repo is cached (reuse from build.rs) - build::ensure_helix_repo_cached().await?; + build::ensure_helix_repo_cached()?; // Step 3: Prepare instance workspace (reuse from build.rs) - build::prepare_instance_workspace(project, instance_name).await?; + build::prepare_instance_workspace(project, instance_name)?; // Step 4: Compile project - generate queries.rs (reuse from build.rs) let metrics_data = build::compile_project(project, instance_name).await?; @@ -115,7 +115,12 @@ async fn check_all_instances( ) -> Result<()> { print_status("CHECK", "Checking all instances"); - let instances: Vec = project.config.list_instances().into_iter().map(String::from).collect(); + let instances: Vec = project + .config + .list_instances() + .into_iter() + .map(String::from) + .collect(); if instances.is_empty() { return Err(eyre::eyre!( @@ -193,7 +198,8 @@ fn handle_cargo_check_failure( print_warning("You can report this issue to help improve Helix."); println!(); - let should_create = print_confirm("Would you like to create a GitHub issue with diagnostic information?")?; + let should_create = + print_confirm("Would you like to create a GitHub issue with diagnostic information?")?; if !should_create { return Ok(()); diff --git a/helix-cli/src/commands/delete.rs b/helix-cli/src/commands/delete.rs index dde96844b..f35ac5bfb 100644 --- a/helix-cli/src/commands/delete.rs +++ b/helix-cli/src/commands/delete.rs @@ -62,7 +62,10 @@ pub async fn run(instance_name: String) -> Result<()> { } // Remove instance volumes (permanent data loss) - let volume = project.instance_volume(&instance_name); + let volume = match &instance_config { + InstanceInfo::Local(_) => project.instance_data_dir(&instance_name)?, + _ => project.instance_volume(&instance_name), + }; if volume.exists() { std::fs::remove_dir_all(&volume)?; print_status("DELETE", "Removed persistent volumes"); diff --git a/helix-cli/src/commands/migrate.rs b/helix-cli/src/commands/migrate.rs index 14f74efc7..52f20c813 100644 --- a/helix-cli/src/commands/migrate.rs +++ b/helix-cli/src/commands/migrate.rs @@ -431,6 +431,7 @@ fn create_v2_config(ctx: &MigrationContext) -> Result<()> { let local_config = LocalInstanceConfig { port: Some(ctx.port), build_mode: BuildMode::Debug, + data_dir: None, db_config, }; diff --git a/helix-cli/src/commands/mod.rs b/helix-cli/src/commands/mod.rs index c7debe34d..bc065c01c 100644 --- a/helix-cli/src/commands/mod.rs +++ b/helix-cli/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod add; pub mod auth; pub mod backup; +pub mod branch; pub mod build; pub mod check; pub mod compile; diff --git a/helix-cli/src/commands/push.rs b/helix-cli/src/commands/push.rs index b26b9c66b..80a005e7c 100644 --- a/helix-cli/src/commands/push.rs +++ b/helix-cli/src/commands/push.rs @@ -149,9 +149,10 @@ async fn push_local_instance( println!(" Local URL: http://localhost:{port}"); let project_name = &project.config.project.name; println!(" Container: helix_{project_name}_{instance_name}"); + let data_dir = project.instance_data_dir(instance_name)?; println!( " Data volume: {}", - project.instance_volume(instance_name).display() + data_dir.display() ); Ok(metrics_data) diff --git a/helix-cli/src/commands/start.rs b/helix-cli/src/commands/start.rs index bf312477e..32b46018b 100644 --- a/helix-cli/src/commands/start.rs +++ b/helix-cli/src/commands/start.rs @@ -72,9 +72,10 @@ async fn start_local_instance(project: &ProjectContext, instance_name: &str) -> println!(" Local URL: http://localhost:{port}"); let project_name = &project.config.project.name; println!(" Container: helix_{project_name}_{instance_name}"); + let data_dir = project.instance_data_dir(instance_name)?; println!( " Data volume: {}", - project.instance_volume(instance_name).display() + data_dir.display() ); Ok(()) diff --git a/helix-cli/src/config.rs b/helix-cli/src/config.rs index e44ccbe7e..f54aa4025 100644 --- a/helix-cli/src/config.rs +++ b/helix-cli/src/config.rs @@ -40,6 +40,19 @@ where serializer.serialize_str(&path.to_string_lossy()) } +fn serialize_optional_path( + path: &Option, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + match path { + Some(path) => serializer.serialize_some(&path.to_string_lossy()), + None => serializer.serialize_none(), + } +} + fn deserialize_path<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -48,6 +61,14 @@ where // Normalize path separators for cross-platform compatibility Ok(PathBuf::from(s.replace('\\', "/"))) } + +fn deserialize_optional_path<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let s = Option::::deserialize(deserializer)?; + Ok(s.map(|path| PathBuf::from(path.replace('\\', "/")))) +} #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ContainerRuntime { @@ -121,6 +142,13 @@ pub struct LocalInstanceConfig { pub port: Option, #[serde(default = "default_dev_build_mode")] pub build_mode: BuildMode, + #[serde( + default, + serialize_with = "serialize_optional_path", + deserialize_with = "deserialize_optional_path", + skip_serializing_if = "Option::is_none" + )] + pub data_dir: Option, #[serde(flatten)] pub db_config: DbConfig, } @@ -271,6 +299,15 @@ impl<'a> InstanceInfo<'a> { } } + pub fn data_dir(&self) -> Option<&PathBuf> { + match self { + InstanceInfo::Local(config) => config.data_dir.as_ref(), + InstanceInfo::Helix(_) => None, + InstanceInfo::FlyIo(_) => None, + InstanceInfo::Ecr(_) => None, + } + } + pub fn cluster_id(&self) -> Option<&str> { match self { InstanceInfo::Local(_) => None, @@ -467,6 +504,7 @@ impl HelixConfig { LocalInstanceConfig { port: Some(6969), build_mode: BuildMode::Debug, + data_dir: None, db_config: DbConfig::default(), }, ); diff --git a/helix-cli/src/docker.rs b/helix-cli/src/docker.rs index 515c82d03..b0014fcaf 100644 --- a/helix-cli/src/docker.rs +++ b/helix-cli/src/docker.rs @@ -150,6 +150,16 @@ impl<'a> DockerManager<'a> { format!("{project_name}_net") } + /// Format a Docker Compose volume spec, keeping Windows absolute paths safe. + fn compose_volume_spec(&self, volume_source: &str) -> String { + if cfg!(windows) && volume_source.contains(':') { + let normalized = volume_source.replace('\\', "/"); + format!("\"{}:{}\"", normalized, HELIX_DATA_DIR) + } else { + format!("{}:{}", volume_source, HELIX_DATA_DIR) + } + } + // === CENTRALIZED DOCKER/PODMAN COMMAND EXECUTION === /// Run a docker/podman command with consistent error handling @@ -544,6 +554,19 @@ CMD ["helix-container"] instance_config: InstanceInfo<'_>, ) -> Result { let port = instance_config.port().unwrap_or(6969); + let default_volume = self.project.instance_volume(instance_name); + let volume_source = if instance_config.is_local() { + let data_dir = self.project.instance_data_dir(instance_name)?; + if data_dir == default_volume { + format!("../.volumes/{instance_name}") + } else { + data_dir.display().to_string() + } + } else { + format!("../.volumes/{instance_name}") + }; + + let volume_spec = self.compose_volume_spec(&volume_source); // Use centralized naming methods let service_name = Self::service_name(); @@ -570,7 +593,7 @@ services: ports: - "{port}:{port}" volumes: - - ../.volumes/{instance_name}:/data + - {volume_spec} environment: {env_section} restart: unless-stopped diff --git a/helix-cli/src/main.rs b/helix-cli/src/main.rs index 36a4c329c..09df82b37 100644 --- a/helix-cli/src/main.rs +++ b/helix-cli/src/main.rs @@ -190,6 +190,24 @@ enum Commands { output: Option, }, + /// Branch a local instance database into a new local instance + Branch { + /// Instance name to branch + instance: String, + + /// Output directory for the branch + #[arg(short, long)] + output: Option, + + /// Name of the branched instance + #[arg(long)] + name: Option, + + /// Port for the branched instance + #[arg(long)] + port: Option, + }, + /// Send feedback to the Helix team Feedback { /// Feedback message (opens interactive prompt if not provided) @@ -229,7 +247,9 @@ async fn main() -> Result<()> { Commands::Build { instance } => commands::build::run(instance, &metrics_sender) .await .map(|_| ()), - Commands::Push { instance, dev } => commands::push::run(instance, dev, &metrics_sender).await, + Commands::Push { instance, dev } => { + commands::push::run(instance, dev, &metrics_sender).await + } Commands::Pull { instance } => commands::pull::run(instance).await, Commands::Start { instance } => commands::start::run(instance).await, Commands::Stop { instance } => commands::stop::run(instance).await, @@ -251,6 +271,12 @@ async fn main() -> Result<()> { commands::migrate::run(path, queries_dir, instance_name, port, dry_run, no_backup).await } Commands::Backup { instance, output } => commands::backup::run(output, instance).await, + Commands::Branch { + instance, + output, + name, + port, + } => commands::branch::run(instance, output, name, port, &metrics_sender).await, Commands::Feedback { message } => commands::feedback::run(message).await, }; diff --git a/helix-cli/src/project.rs b/helix-cli/src/project.rs index 737b21666..868962e7b 100644 --- a/helix-cli/src/project.rs +++ b/helix-cli/src/project.rs @@ -1,4 +1,4 @@ -use crate::config::HelixConfig; +use crate::config::{HelixConfig, InstanceInfo}; use eyre::{Result, eyre}; use std::env; use std::path::{Path, PathBuf}; @@ -46,6 +46,30 @@ impl ProjectContext { self.volumes_dir().join(instance_name) } + /// Get the data directory for a specific instance (respects custom data_dir for local instances) + pub fn instance_data_dir(&self, instance_name: &str) -> Result { + let instance_config = self.config.get_instance(instance_name)?; + let data_dir = instance_config.data_dir().cloned(); + + let data_dir = match data_dir { + Some(path) if path.is_absolute() => path, + Some(path) => self.root.join(path), + None => self.instance_volume(instance_name), + }; + + Ok(data_dir) + } + + /// Get the LMDB user directory for a specific instance + pub fn instance_user_dir(&self, instance_name: &str) -> Result { + Ok(self.instance_data_dir(instance_name)?.join("user")) + } + + /// Get the LMDB data.mdb file path for a specific instance + pub fn instance_data_file(&self, instance_name: &str) -> Result { + Ok(self.instance_user_dir(instance_name)?.join("data.mdb")) + } + /// Get the docker-compose file path for an instance pub fn docker_compose_path(&self, instance_name: &str) -> PathBuf { self.instance_workspace(instance_name) @@ -66,7 +90,16 @@ impl ProjectContext { /// Ensure all necessary directories exist for an instance pub fn ensure_instance_dirs(&self, instance_name: &str) -> Result<()> { let workspace = self.instance_workspace(instance_name); - let volume = self.instance_volume(instance_name); + let volume = self + .config + .get_instance(instance_name) + .ok() + .and_then(|instance| match instance { + InstanceInfo::Local(config) => config.data_dir.as_ref().cloned(), + _ => None, + }) + .map(|path| if path.is_absolute() { path } else { self.root.join(path) }) + .unwrap_or_else(|| self.instance_volume(instance_name)); let container = self.container_dir(instance_name); std::fs::create_dir_all(&workspace)?; @@ -110,8 +143,24 @@ fn find_project_root(start: &Path) -> Result { } pub fn get_helix_cache_dir() -> Result { - let home = dirs::home_dir().ok_or_else(|| eyre!("Cannot find home directory"))?; - let helix_dir = home.join(".helix"); + if let Ok(cache_dir) = env::var("HELIX_CACHE_DIR") { + let helix_dir = PathBuf::from(cache_dir); + std::fs::create_dir_all(&helix_dir)?; + return Ok(helix_dir); + } + + let helix_dir = if cfg!(test) { + let thread_id = format!("{:?}", std::thread::current().id()); + let pid = std::process::id(); + std::env::temp_dir() + .join("helix-test-cache") + .join(pid.to_string()) + .join(thread_id) + .join(".helix") + } else { + let home = dirs::home_dir().ok_or_else(|| eyre!("Cannot find home directory"))?; + home.join(".helix") + }; // Check if this is a fresh installation (no .helix directory exists) let is_fresh_install = !helix_dir.exists(); diff --git a/helix-cli/src/tests/backup_tests.rs b/helix-cli/src/tests/backup_tests.rs new file mode 100644 index 000000000..cd4728cfd --- /dev/null +++ b/helix-cli/src/tests/backup_tests.rs @@ -0,0 +1,67 @@ +use crate::commands::backup::backup_instance_to_dir; +use crate::config::{BuildMode, DbConfig, HelixConfig, LocalInstanceConfig}; +use crate::project::ProjectContext; +use heed3::EnvOpenOptions; +use std::fs; +use tempfile::TempDir; + +fn setup_test_project() -> (TempDir, ProjectContext) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let project_path = temp_dir.path().to_path_buf(); + + let config = HelixConfig::default_config("test-project"); + let config_path = project_path.join("helix.toml"); + config + .save_to_file(&config_path) + .expect("Failed to save config"); + + fs::create_dir_all(project_path.join(".helix")).expect("Failed to create .helix"); + + let context = + ProjectContext::find_and_load(Some(&project_path)).expect("Failed to load project context"); + + (temp_dir, context) +} + +#[test] +fn test_backup_instance_to_dir_copies_data_file() { + let (temp_dir, mut project) = setup_test_project(); + let instance_name = "primary"; + let data_dir = project.root.join("data-dir"); + + project.config.local.insert( + instance_name.to_string(), + LocalInstanceConfig { + port: Some(7777), + build_mode: BuildMode::Debug, + data_dir: Some(data_dir.clone()), + db_config: DbConfig::default(), + }, + ); + + let user_dir = data_dir.join("user"); + fs::create_dir_all(&user_dir).expect("Failed to create user dir"); + { + let env = unsafe { + EnvOpenOptions::new() + .max_dbs(1) + .open(&user_dir) + .expect("Failed to create LMDB env") + }; + drop(env); + } + assert!( + user_dir.join("data.mdb").exists(), + "Expected LMDB data.mdb to exist" + ); + + let output_dir = temp_dir.path().join("backup-output"); + let completed = + backup_instance_to_dir(&project, instance_name, &output_dir).expect("Backup failed"); + + assert!(completed, "Expected backup to complete"); + assert!( + output_dir.join("data.mdb").exists(), + "Expected backup data.mdb to exist" + ); +} diff --git a/helix-cli/src/tests/branch_tests.rs b/helix-cli/src/tests/branch_tests.rs new file mode 100644 index 000000000..7b5e7fe99 --- /dev/null +++ b/helix-cli/src/tests/branch_tests.rs @@ -0,0 +1,88 @@ +use crate::commands::branch::{persist_branch_config, prepare_output_user_dir, resolve_output_dir}; +use crate::config::{BuildMode, DbConfig, HelixConfig, LocalInstanceConfig}; +use crate::project::ProjectContext; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +fn setup_test_project() -> (TempDir, ProjectContext) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let project_path = temp_dir.path().to_path_buf(); + + let config = HelixConfig::default_config("test-project"); + let config_path = project_path.join("helix.toml"); + config + .save_to_file(&config_path) + .expect("Failed to save config"); + + fs::create_dir_all(project_path.join(".helix")).expect("Failed to create .helix"); + + let context = + ProjectContext::find_and_load(Some(&project_path)).expect("Failed to load project context"); + + (temp_dir, context) +} + +#[test] +fn test_resolve_output_dir_defaults() { + let (_temp_dir, project) = setup_test_project(); + let branch_name = "branch-1"; + + let branch_output = resolve_output_dir(&project, None, branch_name); + assert_eq!( + branch_output, + project.helix_dir.join(".volumes").join(branch_name) + ); +} + +#[test] +fn test_resolve_output_dir_relative_path() { + let (_temp_dir, project) = setup_test_project(); + let output = resolve_output_dir( + &project, + Some(PathBuf::from("custom-output")), + "branch-1", + ); + assert_eq!(output, project.root.join("custom-output")); +} + +#[test] +fn test_branch_persists_deploy_config() { + let (_temp_dir, mut project) = setup_test_project(); + let branch_name = "branch-1"; + let output_dir = project.root.join("branch-output"); + + let branch_config = LocalInstanceConfig { + port: Some(7777), + build_mode: BuildMode::Debug, + data_dir: Some(output_dir.clone()), + db_config: DbConfig::default(), + }; + + persist_branch_config(&mut project, branch_name, branch_config) + .expect("Failed to persist branch config"); + + let config_path = project.root.join("helix.toml"); + let reloaded = HelixConfig::from_file(&config_path).expect("Failed to reload config"); + let saved = reloaded + .local + .get(branch_name) + .expect("Missing persisted branch config"); + + assert_eq!(saved.port, Some(7777)); + assert_eq!(saved.data_dir.as_ref(), Some(&output_dir)); +} + +#[test] +fn test_prepare_output_user_dir_rejects_existing_data() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_user_dir = temp_dir.path().join("branch-output").join("user"); + fs::create_dir_all(&output_user_dir).expect("Failed to create output dir"); + fs::write(output_user_dir.join("data.mdb"), "stub").expect("Failed to write data.mdb"); + + let result = prepare_output_user_dir(&output_user_dir); + assert!( + result.is_err(), + "Expected error when output directory already exists" + ); +} diff --git a/helix-cli/src/tests/check_tests.rs b/helix-cli/src/tests/check_tests.rs index 71f9a5024..2b9389fc7 100644 --- a/helix-cli/src/tests/check_tests.rs +++ b/helix-cli/src/tests/check_tests.rs @@ -246,7 +246,7 @@ async fn test_check_with_multiple_instances() { port: Some(6970), build_mode: crate::config::BuildMode::Debug, db_config: DbConfig::default(), - + data_dir: None, }, ); config.local.insert( @@ -255,7 +255,7 @@ async fn test_check_with_multiple_instances() { port: Some(6971), build_mode: crate::config::BuildMode::Debug, db_config: DbConfig::default(), - + data_dir: None, }, ); let config_path = project_path.join("helix.toml"); diff --git a/helix-cli/src/tests/mod.rs b/helix-cli/src/tests/mod.rs index c321e6c36..9703cbad8 100644 --- a/helix-cli/src/tests/mod.rs +++ b/helix-cli/src/tests/mod.rs @@ -5,6 +5,10 @@ pub mod init_tests; pub mod check_tests; #[cfg(test)] pub mod compile_tests; +#[cfg(test)] +pub mod branch_tests; +#[cfg(test)] +pub mod backup_tests; // #[cfg(test)] // pub mod build_tests; // #[cfg(test)]