diff --git a/src/env.rs b/src/env.rs index 231f40d..f911c45 100644 --- a/src/env.rs +++ b/src/env.rs @@ -509,6 +509,67 @@ mod tests { assert_eq!(env["ECLUSE_POSTGRES_PORT"], "11533"); assert_eq!(env["PGPORT"], "11533"); // extra_port: 11532 + 1 } + + // ── parse_env_file ──────────────────────────────────────────────────────── + + #[test] + fn parse_env_file_skips_comments_and_blanks() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(".env.ecluse"); + std::fs::write(&path, "# comment=ignored\n\nPORT=3001\n # indented\n").unwrap(); + let pairs = parse_env_file(&path); + assert_eq!(pairs, vec![("PORT".to_string(), "3001".to_string())]); + } + + #[test] + fn parse_env_file_splits_on_first_equals() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(".env.ecluse"); + std::fs::write(&path, "DATABASE_URL=postgres://x:5433/db?a=b\n").unwrap(); + let pairs = parse_env_file(&path); + assert_eq!(pairs[0].1, "postgres://x:5433/db?a=b"); + } + + #[test] + fn parse_env_file_missing_file_is_empty() { + assert!(parse_env_file(Path::new("/nonexistent/.env.ecluse")).is_empty()); + } + + #[test] + fn parse_env_file_preserves_file_order() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(".env.ecluse"); + std::fs::write(&path, "B=2\nA=1\n").unwrap(); + let keys: Vec = parse_env_file(&path).into_iter().map(|(k, _)| k).collect(); + assert_eq!(keys, vec!["B", "A"]); + } +} + +/// Parse a .env-style file into key/value pairs, in file order. +/// Blank lines and `#` comments are skipped; each line splits on the first +/// `=`; keys are trimmed, values taken as-is. Missing file → empty. +/// +/// The single parser for `.env.ecluse` — `shell`, `env`, `up --json`, and +/// process spawning must all read the file the same way. +pub fn parse_env_file(path: &Path) -> Vec<(String, String)> { + let Ok(content) = std::fs::read_to_string(path) else { + return vec![]; + }; + content + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let (k, v) = line.split_once('=')?; + let k = k.trim(); + if k.is_empty() { + return None; + } + Some((k.to_string(), v.to_string())) + }) + .collect() } pub fn write_env_file(worktree: &Path, env: &HashMap) -> Result<()> { diff --git a/src/main.rs b/src/main.rs index 4442aae..ed23780 100644 --- a/src/main.rs +++ b/src/main.rs @@ -792,7 +792,7 @@ fn cmd_up_resume( if args.force { // Kill all non-skipped services. log.step("--force: killing services on allocated ports..."); - force_kill_session_services(&existing, &config, &explicit_skip, &log); + force_kill_session_services(&existing, &config, &root, &explicit_skip, &log); } else { // Auto-detect already-running services and add them to skip set. log.step("Checking service health..."); @@ -914,37 +914,20 @@ fn cmd_up_resume( } /// Kill all non-skipped services for a session. -/// Native: kill by PID files, then by port (lsof). Docker: docker stop by container name. +/// Native: kill by port (lsof) for PIDs this session owns, then by PID files. +/// Docker: docker stop by container name. fn force_kill_session_services( session: &state::Session, config: &config::Config, + root: &std::path::Path, skip: &std::collections::HashSet, log: &log::StepLogger, ) { - // Kill native via existing PID files. - if let Some(pm) = &session.process_manager { - let result = session.spawn_result(); - let pid_files_to_kill: Vec<_> = result - .pid_files - .iter() - .filter(|pf| { - let svc_name = pf.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - !skip.contains(svc_name) - }) - .cloned() - .collect(); - let filtered_result = process::SpawnResult { - tmux_session: result.tmux_session.clone(), - pid_files: pid_files_to_kill, - log_dir: result.log_dir.clone(), - }; - process::kill_services(pm, &filtered_result); - } - - // Kill by port for residual native processes only. - // Docker services are stopped via docker stop — never kill their host port - // by PID, as the listening process may be the container runtime itself - // (e.g. OrbStack) rather than the container. + // Kill by port first, while the session's pid files still exist — they + // are how ownership is established. Docker services are stopped via + // docker stop — never kill their host port by PID, as the listening + // process may be the container runtime itself (e.g. OrbStack) rather + // than the container. let docker_svc_names: std::collections::HashSet<&str> = config .services .iter() @@ -959,26 +942,56 @@ fn force_kill_session_services( if docker_svc_names.contains(svc_name.as_str()) { continue; } - // lsof -ti TCP: returns PIDs; kill -9 each let output = std::process::Command::new("lsof") .args(["-ti", &format!("TCP:{}", port), "-sTCP:LISTEN"]) .output(); if let Ok(out) = output { let stdout = String::from_utf8_lossy(&out.stdout); for pid_str in stdout.split_whitespace() { - if let Ok(pid) = pid_str.trim().parse::() { - log.detail(&format!( - "killed process {} on port {} ({})", - pid, port, svc_name + let Ok(pid) = pid_str.trim().parse::() else { + continue; + }; + // The recorded port may be stale — another session's + // auto-bumped service or an unrelated app could hold it by + // now. Only kill PIDs that resolve to THIS session. + let owned = whose_pid::resolve(root, std::slice::from_ref(session), pid) + .is_some_and(|o| o.slug == session.slug); + if !owned { + log.warn(&format!( + "port {} is held by PID {} which is not owned by session '{}'; skipping — kill it manually if intended", + port, pid, session.slug )); - let _ = std::process::Command::new("kill") - .args(["-9", pid_str.trim()]) - .status(); + continue; } + log.detail(&format!( + "killing process {} on port {} ({})", + pid, port, svc_name + )); + terminate_with_grace(pid); } } } + // Kill native via existing PID files. + if let Some(pm) = &session.process_manager { + let result = session.spawn_result(); + let pid_files_to_kill: Vec<_> = result + .pid_files + .iter() + .filter(|pf| { + let svc_name = pf.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + !skip.contains(svc_name) + }) + .cloned() + .collect(); + let filtered_result = process::SpawnResult { + tmux_session: result.tmux_session.clone(), + pid_files: pid_files_to_kill, + log_dir: result.log_dir.clone(), + }; + process::kill_services(pm, &filtered_result); + } + // Stop docker containers for non-skipped docker services. let docker_svcs: Vec<_> = config .services @@ -994,6 +1007,23 @@ fn force_kill_session_services( } } +/// SIGTERM, escalating to SIGKILL after a short grace period if still alive. +fn terminate_with_grace(pid: u32) { + let _ = std::process::Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .status(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while process::pid_alive(pid) { + if std::time::Instant::now() >= deadline { + let _ = std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .status(); + return; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } +} + fn print_up_summary(session: &state::Session, _config: &config::Config, log: &log::StepLogger) { println!(); log.success(&format!( @@ -1029,12 +1059,8 @@ fn print_up_summary(session: &state::Session, _config: &config::Config, log: &lo fn print_up_json(session: &state::Session, _root: &std::path::Path) -> Result<()> { let env_file = std::path::Path::new(&session.worktree_path).join(".env.ecluse"); let mut env_vars: serde_json::Map = serde_json::Map::new(); - if env_file.exists() { - for line in std::fs::read_to_string(&env_file)?.lines() { - if let Some((k, v)) = line.split_once('=') { - env_vars.insert(k.to_string(), serde_json::Value::String(v.to_string())); - } - } + for (k, v) in env::parse_env_file(&env_file) { + env_vars.insert(k, serde_json::Value::String(v)); } let out = serde_json::json!({ "slug": session.slug, @@ -1250,7 +1276,11 @@ fn cmd_ls(args: cli::LsArgs) -> Result<()> { ports, tmux: s.tmux_session.clone().unwrap_or_default(), branch: s.branch.clone(), - started: s.started_at[..16].replace('T', " "), + started: s + .started_at + .get(..16) + .unwrap_or(&s.started_at) + .replace('T', " "), } }) .collect(); @@ -1301,23 +1331,7 @@ fn cmd_shell(args: cli::ShellArgs) -> Result<()> { let worktree = std::path::Path::new(&session.worktree_path); let env_file = worktree.join(".env.ecluse"); - - let env_vars: Vec<(String, String)> = if env_file.exists() { - std::fs::read_to_string(&env_file) - .context("failed to read .env.ecluse")? - .lines() - .filter_map(|line| { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - return None; - } - let (k, v) = line.split_once('=')?; - Some((k.to_string(), v.to_string())) - }) - .collect() - } else { - vec![] - }; + let env_vars: Vec<(String, String)> = env::parse_env_file(&env_file); if let Some(tmux_session) = &session.tmux_session { println!( @@ -1430,12 +1444,8 @@ fn cmd_env(args: cli::EnvArgs) -> Result<()> { let env_file = std::path::Path::new(&session.worktree_path).join(".env.ecluse"); let mut env_vars: serde_json::Map = serde_json::Map::new(); - if env_file.exists() { - for line in std::fs::read_to_string(&env_file)?.lines() { - if let Some((k, v)) = line.split_once('=') { - env_vars.insert(k.to_string(), serde_json::Value::String(v.to_string())); - } - } + for (k, v) in env::parse_env_file(&env_file) { + env_vars.insert(k, serde_json::Value::String(v)); } let out = serde_json::json!({ @@ -1480,10 +1490,17 @@ fn cmd_sync(args: cli::SyncArgs) -> Result<()> { let path = if canonical.exists() { canonical } else { + // Fall back to the cwd only when it really is a linked git + // worktree of this repo — never on path-string coincidences. let cwd = std::env::current_dir().context("could not determine current directory")?; - if cwd.starts_with(&root) || cwd.to_str().is_some_and(|c| c.contains(s.as_str())) { - cwd + let belongs = worktree::is_inside_git_worktree(&cwd) + && worktree::WorktreeManager::main_worktree_root(&cwd) + .ok() + .and_then(|r| std::fs::canonicalize(r).ok()) + == std::fs::canonicalize(&root).ok(); + if belongs { + worktree::git_worktree_root(&cwd)? } else { return Err(error::EcluseError::WorktreeNotFound { slug: s.clone() }.into()); } diff --git a/src/process.rs b/src/process.rs index c4b9b68..4da1690 100644 --- a/src/process.rs +++ b/src/process.rs @@ -176,24 +176,11 @@ fn tmux_session_exists(session: &str) -> bool { .unwrap_or(false) } -/// Parse a .env-style file into key=value pairs. Lines starting with `#` -/// and blank lines are skipped. Values are not unquoted — taken as-is. +/// Parse a .env-style file into key=value pairs. +/// Delegates to the shared parser so spawning, `shell`, `env`, and +/// `up --json` all read env files identically. fn parse_env_file(path: &Path) -> std::collections::HashMap { - let Ok(content) = std::fs::read_to_string(path) else { - return std::collections::HashMap::new(); - }; - content - .lines() - .filter(|l| !l.trim_start().starts_with('#') && l.contains('=')) - .filter_map(|l| { - let (k, v) = l.split_once('=')?; - let k = k.trim().to_string(); - if k.is_empty() { - return None; - } - Some((k, v.to_string())) - }) - .collect() + crate::env::parse_env_file(path).into_iter().collect() } /// Merge .env → .env.local → .env.ecluse from `worktree` into `base`, diff --git a/src/state.rs b/src/state.rs index 1e96958..df99118 100644 --- a/src/state.rs +++ b/src/state.rs @@ -160,32 +160,18 @@ impl StateGuard { /// Use for commands that only read state: status, ls, env, shell. pub fn acquire_shared(root: &Path) -> Result { let ecluse_dir = root.join(".ecluse"); - let lock_path = ecluse_dir.join("state.lock"); - - // If the lock file doesn't exist yet there's no state either — return empty. - if !lock_path.exists() { - return Ok(StateGuard { - state: State::default(), - state_path: ecluse_dir.join("state.json"), - _lock_file: OpenOptions::new() - .create(true) - .truncate(false) - .write(true) - .open(ecluse_dir.join("state.lock")) - .unwrap_or_else(|_| { - std::fs::create_dir_all(&ecluse_dir).ok(); - OpenOptions::new() - .create(true) - .truncate(false) - .write(true) - .open(&lock_path) - .expect("failed to create lock file") - }), - }); - } + std::fs::create_dir_all(&ecluse_dir) + .with_context(|| format!("failed to create {}", ecluse_dir.display()))?; + // Create the lock file if missing. state.json may still exist (e.g. + // the lock file was removed by hand) — never report an empty state + // just because the lock file is gone. + let lock_path = ecluse_dir.join("state.lock"); let lock_file = OpenOptions::new() .read(true) + .write(true) + .create(true) + .truncate(false) .open(&lock_path) .with_context(|| format!("failed to open lock file {}", lock_path.display()))?; @@ -530,4 +516,22 @@ mod tests { let json = serde_json::to_string(&s).unwrap(); assert!(!json.contains("services_subset"), "got: {json}"); } + + // The lock file can go missing while state.json survives (manual cleanup, + // partial flush). Shared acquisition must recreate the lock and read the + // real state, not silently report "no sessions". + #[test] + fn acquire_shared_reads_state_when_lock_file_missing() { + let dir = TempDir::new().unwrap(); + { + let mut guard = StateGuard::acquire(dir.path()).unwrap(); + guard.state.add_session(make_session("survivor", 1)); + guard.commit().unwrap(); + } + std::fs::remove_file(dir.path().join(".ecluse/state.lock")).unwrap(); + + let guard = StateGuard::acquire_shared(dir.path()).unwrap(); + assert_eq!(guard.state.sessions.len(), 1); + assert_eq!(guard.state.sessions[0].slug, "survivor"); + } } diff --git a/tests/integration.rs b/tests/integration.rs index 64a03cd..e69a0ab 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -448,3 +448,14 @@ fn up_without_init_errors() { stderr(&out) ); } + +#[test] +fn sync_rejects_repo_root_as_worktree() { + let repo = tmp_repo(); + ecluse(repo.path(), &["init", "--mode", "host", "--yes"]); + // No worktree exists for this slug; running from the repo root must not + // register the main checkout as the session's worktree. + let out = ecluse(repo.path(), &["sync", "ghost-slug"]); + assert!(!out.status.success()); + assert!(stderr(&out).contains("ghost-slug"), "got: {}", stderr(&out)); +}