From 3d6735f2a44c513f0506ede81f3e3929f9f88a46 Mon Sep 17 00:00:00 2001 From: slkzgm Date: Thu, 15 Jan 2026 18:40:39 +0100 Subject: [PATCH 1/3] feat(daemon): add workspace management RPC --- REMOTE_BACKEND_POC.md | 5 + src-tauri/src/bin/codex_monitor_daemon.rs | 381 +++++++++++++++++++++- 2 files changed, 385 insertions(+), 1 deletion(-) diff --git a/REMOTE_BACKEND_POC.md b/REMOTE_BACKEND_POC.md index 8b2b264f5..63d0ef07a 100644 --- a/REMOTE_BACKEND_POC.md +++ b/REMOTE_BACKEND_POC.md @@ -52,7 +52,12 @@ printf '{\"id\":3,\"method\":\"list_workspaces\",\"params\":{}}\\n' | nc -w 1 12 - `ping` - `list_workspaces` - `add_workspace` (`{ path, codex_bin? }`) +- `add_worktree` (`{ parentId, branch }`) - `connect_workspace` (`{ id }`) +- `remove_workspace` (`{ id }`) +- `remove_worktree` (`{ id }`) +- `update_workspace_settings` (`{ id, settings }`) +- `update_workspace_codex_bin` (`{ id, codex_bin? }`) - `list_workspace_files` (`{ workspaceId }`) - `get_app_settings` - `update_app_settings` (`{ settings }`) diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 2581f58dd..1a5c51c5e 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -8,6 +8,7 @@ mod types; use serde_json::{json, Map, Value}; use std::collections::HashMap; use std::env; +use std::io::Write; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; @@ -15,13 +16,16 @@ use std::sync::Arc; use ignore::WalkBuilder; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; +use tokio::process::Command; use tokio::sync::{broadcast, mpsc, Mutex}; use uuid::Uuid; use backend::app_server::{spawn_workspace_session, WorkspaceSession}; use backend::events::{AppServerEvent, EventSink, TerminalOutput}; use storage::{read_settings, read_workspaces, write_settings, write_workspaces}; -use types::{AppSettings, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings}; +use types::{ + AppSettings, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings, WorktreeInfo, +}; const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:4732"; @@ -158,6 +162,258 @@ impl DaemonState { }) } + async fn add_worktree( + &self, + parent_id: String, + branch: String, + client_version: String, + ) -> Result { + let branch = branch.trim().to_string(); + if branch.trim().is_empty() { + return Err("Branch name is required.".to_string()); + } + + let parent_entry = { + let workspaces = self.workspaces.lock().await; + workspaces + .get(&parent_id) + .cloned() + .ok_or("parent workspace not found")? + }; + + if parent_entry.kind.is_worktree() { + return Err("Cannot create a worktree from another worktree.".to_string()); + } + + let worktree_root = PathBuf::from(&parent_entry.path).join(".codex-worktrees"); + std::fs::create_dir_all(&worktree_root) + .map_err(|e| format!("Failed to create worktree directory: {e}"))?; + ensure_worktree_ignored(&PathBuf::from(&parent_entry.path))?; + + let safe_name = sanitize_worktree_name(&branch); + let worktree_path = unique_worktree_path(&worktree_root, &safe_name); + let worktree_path_string = worktree_path.to_string_lossy().to_string(); + + let branch_exists = git_branch_exists(&PathBuf::from(&parent_entry.path), &branch).await?; + if branch_exists { + run_git_command( + &PathBuf::from(&parent_entry.path), + &["worktree", "add", &worktree_path_string, &branch], + ) + .await?; + } else { + run_git_command( + &PathBuf::from(&parent_entry.path), + &["worktree", "add", "-b", &branch, &worktree_path_string], + ) + .await?; + } + + let entry = WorkspaceEntry { + id: Uuid::new_v4().to_string(), + name: branch.to_string(), + path: worktree_path_string, + codex_bin: parent_entry.codex_bin.clone(), + kind: WorkspaceKind::Worktree, + parent_id: Some(parent_entry.id.clone()), + worktree: Some(WorktreeInfo { + branch: branch.to_string(), + }), + settings: WorkspaceSettings::default(), + }; + + let default_bin = { + let settings = self.app_settings.lock().await; + settings.codex_bin.clone() + }; + + let session = spawn_workspace_session( + entry.clone(), + default_bin, + client_version, + self.event_sink.clone(), + ) + .await?; + + let list = { + let mut workspaces = self.workspaces.lock().await; + workspaces.insert(entry.id.clone(), entry.clone()); + workspaces.values().cloned().collect::>() + }; + write_workspaces(&self.storage_path, &list)?; + + self.sessions.lock().await.insert(entry.id.clone(), session); + + Ok(WorkspaceInfo { + id: entry.id, + name: entry.name, + path: entry.path, + connected: true, + codex_bin: entry.codex_bin, + kind: entry.kind, + parent_id: entry.parent_id, + worktree: entry.worktree, + settings: entry.settings, + }) + } + + async fn remove_workspace(&self, id: String) -> Result<(), String> { + let (entry, child_worktrees) = { + let workspaces = self.workspaces.lock().await; + let entry = workspaces.get(&id).cloned().ok_or("workspace not found")?; + if entry.kind.is_worktree() { + return Err("Use remove_worktree for worktree agents.".to_string()); + } + let children = workspaces + .values() + .filter(|workspace| workspace.parent_id.as_deref() == Some(&id)) + .cloned() + .collect::>(); + (entry, children) + }; + + let parent_path = PathBuf::from(&entry.path); + for child in &child_worktrees { + if let Some(session) = self.sessions.lock().await.remove(&child.id) { + let mut child_process = session.child.lock().await; + let _ = child_process.kill().await; + } + let child_path = PathBuf::from(&child.path); + if child_path.exists() { + run_git_command( + &parent_path, + &["worktree", "remove", "--force", &child.path], + ) + .await?; + } + } + let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await; + + if let Some(session) = self.sessions.lock().await.remove(&id) { + let mut child = session.child.lock().await; + let _ = child.kill().await; + } + + { + let mut workspaces = self.workspaces.lock().await; + workspaces.remove(&id); + for child in child_worktrees { + workspaces.remove(&child.id); + } + let list: Vec<_> = workspaces.values().cloned().collect(); + write_workspaces(&self.storage_path, &list)?; + } + + Ok(()) + } + + async fn remove_worktree(&self, id: String) -> Result<(), String> { + let (entry, parent) = { + let workspaces = self.workspaces.lock().await; + let entry = workspaces.get(&id).cloned().ok_or("workspace not found")?; + if !entry.kind.is_worktree() { + return Err("Not a worktree workspace.".to_string()); + } + let parent_id = entry.parent_id.clone().ok_or("worktree parent not found")?; + let parent = workspaces + .get(&parent_id) + .cloned() + .ok_or("worktree parent not found")?; + (entry, parent) + }; + + if let Some(session) = self.sessions.lock().await.remove(&entry.id) { + let mut child = session.child.lock().await; + let _ = child.kill().await; + } + + let parent_path = PathBuf::from(&parent.path); + let entry_path = PathBuf::from(&entry.path); + if entry_path.exists() { + run_git_command( + &parent_path, + &["worktree", "remove", "--force", &entry.path], + ) + .await?; + } + let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await; + + { + let mut workspaces = self.workspaces.lock().await; + workspaces.remove(&entry.id); + let list: Vec<_> = workspaces.values().cloned().collect(); + write_workspaces(&self.storage_path, &list)?; + } + + Ok(()) + } + + async fn update_workspace_settings( + &self, + id: String, + settings: WorkspaceSettings, + ) -> Result { + let (entry_snapshot, list) = { + let mut workspaces = self.workspaces.lock().await; + let entry_snapshot = match workspaces.get_mut(&id) { + Some(entry) => { + entry.settings = settings.clone(); + entry.clone() + } + None => return Err("workspace not found".to_string()), + }; + let list: Vec<_> = workspaces.values().cloned().collect(); + (entry_snapshot, list) + }; + write_workspaces(&self.storage_path, &list)?; + + let connected = self.sessions.lock().await.contains_key(&id); + Ok(WorkspaceInfo { + id: entry_snapshot.id, + name: entry_snapshot.name, + path: entry_snapshot.path, + connected, + codex_bin: entry_snapshot.codex_bin, + kind: entry_snapshot.kind, + parent_id: entry_snapshot.parent_id, + worktree: entry_snapshot.worktree, + settings: entry_snapshot.settings, + }) + } + + async fn update_workspace_codex_bin( + &self, + id: String, + codex_bin: Option, + ) -> Result { + let (entry_snapshot, list) = { + let mut workspaces = self.workspaces.lock().await; + let entry_snapshot = match workspaces.get_mut(&id) { + Some(entry) => { + entry.codex_bin = codex_bin.clone(); + entry.clone() + } + None => return Err("workspace not found".to_string()), + }; + let list: Vec<_> = workspaces.values().cloned().collect(); + (entry_snapshot, list) + }; + write_workspaces(&self.storage_path, &list)?; + + let connected = self.sessions.lock().await.contains_key(&id); + Ok(WorkspaceInfo { + id: entry_snapshot.id, + name: entry_snapshot.name, + path: entry_snapshot.path, + connected, + codex_bin: entry_snapshot.codex_bin, + kind: entry_snapshot.kind, + parent_id: entry_snapshot.parent_id, + worktree: entry_snapshot.worktree, + settings: entry_snapshot.settings, + }) + } + async fn connect_workspace(&self, id: String, client_version: String) -> Result<(), String> { { let sessions = self.sessions.lock().await; @@ -481,6 +737,94 @@ fn list_workspace_files_inner(root: &PathBuf, max_files: usize) -> Vec { results } +async fn run_git_command(repo_path: &PathBuf, args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(repo_path) + .output() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + Err("Git command failed.".to_string()) + } else { + Err(detail.to_string()) + } + } +} + +async fn git_branch_exists(repo_path: &PathBuf, branch: &str) -> Result { + let status = Command::new("git") + .args(["show-ref", "--verify", &format!("refs/heads/{branch}")]) + .current_dir(repo_path) + .status() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + Ok(status.success()) +} + +fn sanitize_worktree_name(branch: &str) -> String { + let mut result = String::new(); + for ch in branch.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + result.push(ch); + } else { + result.push('-'); + } + } + let trimmed = result.trim_matches('-').to_string(); + if trimmed.is_empty() { + "worktree".to_string() + } else { + trimmed + } +} + +fn unique_worktree_path(base_dir: &PathBuf, name: &str) -> PathBuf { + let mut candidate = base_dir.join(name); + if !candidate.exists() { + return candidate; + } + for index in 2..1000 { + let next = base_dir.join(format!("{name}-{index}")); + if !next.exists() { + candidate = next; + break; + } + } + candidate +} + +fn ensure_worktree_ignored(repo_path: &PathBuf) -> Result<(), String> { + let ignore_path = repo_path.join(".gitignore"); + let entry = ".codex-worktrees/"; + let existing = std::fs::read_to_string(&ignore_path).unwrap_or_default(); + if existing.lines().any(|line| line.trim() == entry) { + return Ok(()); + } + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&ignore_path) + .map_err(|e| format!("Failed to update .gitignore: {e}"))?; + if !existing.ends_with('\n') && !existing.is_empty() { + file.write_all(b"\n") + .map_err(|e| format!("Failed to update .gitignore: {e}"))?; + } + file.write_all(format!("{entry}\n").as_bytes()) + .map_err(|e| format!("Failed to update .gitignore: {e}"))?; + Ok(()) +} + fn default_data_dir() -> PathBuf { if let Ok(xdg) = env::var("XDG_DATA_HOME") { let trimmed = xdg.trim(); @@ -677,11 +1021,46 @@ async fn handle_rpc_request( let workspace = state.add_workspace(path, codex_bin, client_version).await?; serde_json::to_value(workspace).map_err(|err| err.to_string()) } + "add_worktree" => { + let parent_id = parse_string(¶ms, "parentId")?; + let branch = parse_string(¶ms, "branch")?; + let workspace = state + .add_worktree(parent_id, branch, client_version) + .await?; + serde_json::to_value(workspace).map_err(|err| err.to_string()) + } "connect_workspace" => { let id = parse_string(¶ms, "id")?; state.connect_workspace(id, client_version).await?; Ok(json!({ "ok": true })) } + "remove_workspace" => { + let id = parse_string(¶ms, "id")?; + state.remove_workspace(id).await?; + Ok(json!({ "ok": true })) + } + "remove_worktree" => { + let id = parse_string(¶ms, "id")?; + state.remove_worktree(id).await?; + Ok(json!({ "ok": true })) + } + "update_workspace_settings" => { + let id = parse_string(¶ms, "id")?; + let settings_value = match params { + Value::Object(map) => map.get("settings").cloned().unwrap_or(Value::Null), + _ => Value::Null, + }; + let settings: WorkspaceSettings = + serde_json::from_value(settings_value).map_err(|err| err.to_string())?; + let workspace = state.update_workspace_settings(id, settings).await?; + serde_json::to_value(workspace).map_err(|err| err.to_string()) + } + "update_workspace_codex_bin" => { + let id = parse_string(¶ms, "id")?; + let codex_bin = parse_optional_string(¶ms, "codex_bin"); + let workspace = state.update_workspace_codex_bin(id, codex_bin).await?; + serde_json::to_value(workspace).map_err(|err| err.to_string()) + } "list_workspace_files" => { let workspace_id = parse_string(¶ms, "workspaceId")?; let files = state.list_workspace_files(workspace_id).await?; From 2246ccf46209762ceb374e5f747acdaf8f2cde9e Mon Sep 17 00:00:00 2001 From: slkzgm Date: Fri, 16 Jan 2026 22:59:55 +0100 Subject: [PATCH 2/3] fix(daemon): pass CODEX_HOME when spawning worktrees --- src-tauri/src/bin/codex_monitor_daemon.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 1a5c51c5e..a4b3c65a2 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -227,11 +227,13 @@ impl DaemonState { settings.codex_bin.clone() }; + let codex_home = resolve_codex_home(&entry, Some(&parent_entry.path)); let session = spawn_workspace_session( entry.clone(), default_bin, client_version, self.event_sink.clone(), + codex_home, ) .await?; From 887923c2bd3a512051329be7d210278170e21fad Mon Sep 17 00:00:00 2001 From: slkzgm Date: Sat, 17 Jan 2026 12:57:19 +0100 Subject: [PATCH 3/3] fix(daemon): harden worktree lifecycle --- src-tauri/src/bin/codex_monitor_daemon.rs | 184 ++++++++++++++-------- 1 file changed, 122 insertions(+), 62 deletions(-) diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index a4b3c65a2..1adf45df6 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -8,7 +8,6 @@ mod types; use serde_json::{json, Map, Value}; use std::collections::HashMap; use std::env; -use std::io::Write; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; @@ -57,6 +56,7 @@ struct DaemonConfig { } struct DaemonState { + data_dir: PathBuf, workspaces: Mutex>, sessions: Mutex>>, storage_path: PathBuf, @@ -72,6 +72,7 @@ impl DaemonState { let workspaces = read_workspaces(&storage_path).unwrap_or_default(); let app_settings = read_settings(&settings_path).unwrap_or_default(); Self { + data_dir: config.data_dir.clone(), workspaces: Mutex::new(workspaces), sessions: Mutex::new(HashMap::new()), storage_path, @@ -81,6 +82,20 @@ impl DaemonState { } } + async fn kill_session(&self, workspace_id: &str) { + let session = { + let mut sessions = self.sessions.lock().await; + sessions.remove(workspace_id) + }; + + let Some(session) = session else { + return; + }; + + let mut child = session.child.lock().await; + let _ = child.kill().await; + } + async fn list_workspaces(&self) -> Vec { let workspaces = self.workspaces.lock().await; let sessions = self.sessions.lock().await; @@ -185,25 +200,31 @@ impl DaemonState { return Err("Cannot create a worktree from another worktree.".to_string()); } - let worktree_root = PathBuf::from(&parent_entry.path).join(".codex-worktrees"); + let worktree_root = self.data_dir.join("worktrees").join(&parent_entry.id); std::fs::create_dir_all(&worktree_root) .map_err(|e| format!("Failed to create worktree directory: {e}"))?; - ensure_worktree_ignored(&PathBuf::from(&parent_entry.path))?; let safe_name = sanitize_worktree_name(&branch); - let worktree_path = unique_worktree_path(&worktree_root, &safe_name); + let worktree_path = unique_worktree_path(&worktree_root, &safe_name)?; let worktree_path_string = worktree_path.to_string_lossy().to_string(); - let branch_exists = git_branch_exists(&PathBuf::from(&parent_entry.path), &branch).await?; + let repo_path = PathBuf::from(&parent_entry.path); + let branch_exists = git_branch_exists(&repo_path, &branch).await?; if branch_exists { run_git_command( - &PathBuf::from(&parent_entry.path), + &repo_path, &["worktree", "add", &worktree_path_string, &branch], ) .await?; + } else if let Some(remote_ref) = git_find_remote_tracking_branch(&repo_path, &branch).await? { + run_git_command( + &repo_path, + &["worktree", "add", "-b", &branch, &worktree_path_string, &remote_ref], + ) + .await?; } else { run_git_command( - &PathBuf::from(&parent_entry.path), + &repo_path, &["worktree", "add", "-b", &branch, &worktree_path_string], ) .await?; @@ -274,39 +295,57 @@ impl DaemonState { (entry, children) }; - let parent_path = PathBuf::from(&entry.path); + let repo_path = PathBuf::from(&entry.path); + let mut removed_child_ids = Vec::new(); + let mut failures = Vec::new(); + for child in &child_worktrees { - if let Some(session) = self.sessions.lock().await.remove(&child.id) { - let mut child_process = session.child.lock().await; - let _ = child_process.kill().await; - } let child_path = PathBuf::from(&child.path); if child_path.exists() { - run_git_command( - &parent_path, + if let Err(err) = run_git_command( + &repo_path, &["worktree", "remove", "--force", &child.path], ) - .await?; + .await + { + failures.push((child.id.clone(), err)); + continue; + } } + + self.kill_session(&child.id).await; + removed_child_ids.push(child.id.clone()); } - let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await; - if let Some(session) = self.sessions.lock().await.remove(&id) { - let mut child = session.child.lock().await; - let _ = child.kill().await; + let _ = run_git_command(&repo_path, &["worktree", "prune", "--expire", "now"]).await; + + let mut ids_to_remove = removed_child_ids; + if failures.is_empty() { + self.kill_session(&id).await; + ids_to_remove.push(id.clone()); } - { - let mut workspaces = self.workspaces.lock().await; - workspaces.remove(&id); - for child in child_worktrees { - workspaces.remove(&child.id); - } - let list: Vec<_> = workspaces.values().cloned().collect(); + if !ids_to_remove.is_empty() { + let list = { + let mut workspaces = self.workspaces.lock().await; + for workspace_id in ids_to_remove { + workspaces.remove(&workspace_id); + } + workspaces.values().cloned().collect::>() + }; write_workspaces(&self.storage_path, &list)?; } - Ok(()) + if failures.is_empty() { + return Ok(()); + } + + let mut message = + "Failed to remove one or more worktrees; parent workspace was not removed.".to_string(); + for (child_id, error) in failures { + message.push_str(&format!("\n- {child_id}: {error}")); + } + Err(message) } async fn remove_worktree(&self, id: String) -> Result<(), String> { @@ -324,11 +363,6 @@ impl DaemonState { (entry, parent) }; - if let Some(session) = self.sessions.lock().await.remove(&entry.id) { - let mut child = session.child.lock().await; - let _ = child.kill().await; - } - let parent_path = PathBuf::from(&parent.path); let entry_path = PathBuf::from(&entry.path); if entry_path.exists() { @@ -340,12 +374,14 @@ impl DaemonState { } let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await; - { + self.kill_session(&entry.id).await; + + let list = { let mut workspaces = self.workspaces.lock().await; workspaces.remove(&entry.id); - let list: Vec<_> = workspaces.values().cloned().collect(); - write_workspaces(&self.storage_path, &list)?; - } + workspaces.values().cloned().collect::>() + }; + write_workspaces(&self.storage_path, &list)?; Ok(()) } @@ -774,6 +810,47 @@ async fn git_branch_exists(repo_path: &PathBuf, branch: &str) -> Result Result { + let status = Command::new("git") + .args([ + "show-ref", + "--verify", + &format!("refs/remotes/{remote}/{branch}"), + ]) + .current_dir(repo_path) + .status() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + Ok(status.success()) +} + +async fn git_list_remotes(repo_path: &PathBuf) -> Result, String> { + let output = run_git_command(repo_path, &["remote"]).await?; + Ok(output + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .map(|line| line.to_string()) + .collect()) +} + +async fn git_find_remote_tracking_branch(repo_path: &PathBuf, branch: &str) -> Result, String> { + if git_remote_branch_exists(repo_path, "origin", branch).await? { + return Ok(Some(format!("origin/{branch}"))); + } + + for remote in git_list_remotes(repo_path).await? { + if remote == "origin" { + continue; + } + if git_remote_branch_exists(repo_path, &remote, branch).await? { + return Ok(Some(format!("{remote}/{branch}"))); + } + } + + Ok(None) +} + fn sanitize_worktree_name(branch: &str) -> String { let mut result = String::new(); for ch in branch.chars() { @@ -791,40 +868,23 @@ fn sanitize_worktree_name(branch: &str) -> String { } } -fn unique_worktree_path(base_dir: &PathBuf, name: &str) -> PathBuf { - let mut candidate = base_dir.join(name); +fn unique_worktree_path(base_dir: &PathBuf, name: &str) -> Result { + let candidate = base_dir.join(name); if !candidate.exists() { - return candidate; + return Ok(candidate); } + for index in 2..1000 { let next = base_dir.join(format!("{name}-{index}")); if !next.exists() { - candidate = next; - break; + return Ok(next); } } - candidate -} -fn ensure_worktree_ignored(repo_path: &PathBuf) -> Result<(), String> { - let ignore_path = repo_path.join(".gitignore"); - let entry = ".codex-worktrees/"; - let existing = std::fs::read_to_string(&ignore_path).unwrap_or_default(); - if existing.lines().any(|line| line.trim() == entry) { - return Ok(()); - } - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&ignore_path) - .map_err(|e| format!("Failed to update .gitignore: {e}"))?; - if !existing.ends_with('\n') && !existing.is_empty() { - file.write_all(b"\n") - .map_err(|e| format!("Failed to update .gitignore: {e}"))?; - } - file.write_all(format!("{entry}\n").as_bytes()) - .map_err(|e| format!("Failed to update .gitignore: {e}"))?; - Ok(()) + Err(format!( + "Failed to find an available worktree path under {}.", + base_dir.display() + )) } fn default_data_dir() -> PathBuf {