diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index a663494a1bdeec..62048d4c67d793 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -1395,19 +1395,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn set_title( &self, session_id: &acp::SessionId, - cx: &App, + _cx: &App, ) -> Option> { - self.0.read_with(cx, |agent, _cx| { - agent - .sessions - .get(session_id) - .filter(|s| !s.thread.read(cx).is_subagent()) - .map(|session| { - Rc::new(NativeAgentSessionSetTitle { - thread: session.thread.clone(), - }) as _ - }) - }) + Some(Rc::new(NativeAgentSessionSetTitle { + connection: self.clone(), + session_id: session_id.clone(), + }) as _) } fn session_list(&self, cx: &mut App) -> Option> { @@ -1466,12 +1459,21 @@ impl NativeAgentSessionList { } fn to_session_info(entry: DbThreadMetadata) -> AgentSessionInfo { + let meta = entry.worktree_branch.map(|branch| { + let mut map = serde_json::Map::new(); + map.insert( + "worktree_branch".to_string(), + serde_json::Value::String(branch), + ); + map + }); + AgentSessionInfo { session_id: entry.id, cwd: None, title: Some(entry.title), updated_at: Some(entry.updated_at), - meta: None, + meta, } } @@ -1566,13 +1568,17 @@ impl acp_thread::AgentSessionRetry for NativeAgentSessionRetry { } struct NativeAgentSessionSetTitle { - thread: Entity, + connection: NativeAgentConnection, + session_id: acp::SessionId, } impl acp_thread::AgentSessionSetTitle for NativeAgentSessionSetTitle { fn run(&self, title: SharedString, cx: &mut App) -> Task> { - self.thread - .update(cx, |thread, cx| thread.set_title(title, cx)); + let Some(session) = self.connection.0.read(cx).sessions.get(&self.session_id) else { + return Task::ready(Err(anyhow!("session not found"))); + }; + let thread = session.thread.clone(); + thread.update(cx, |thread, cx| thread.set_title(title, cx)); Task::ready(Ok(())) } } diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index fa4b37dba3e789..eb9e4026613d4c 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -23,6 +23,17 @@ pub type DbMessage = crate::Message; pub type DbSummary = crate::legacy_thread::DetailedSummaryState; pub type DbLanguageModel = crate::legacy_thread::SerializedLanguageModel; +/// Metadata about the git worktree associated with an agent thread. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentGitWorktreeInfo { + /// The branch name in the git worktree. + pub branch: String, + /// Absolute path to the git worktree on disk. + pub worktree_path: std::path::PathBuf, + /// The base branch/commit the worktree was created from. + pub base_ref: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DbThreadMetadata { pub id: acp::SessionId, @@ -30,6 +41,7 @@ pub struct DbThreadMetadata { #[serde(alias = "summary")] pub title: SharedString, pub updated_at: DateTime, + pub worktree_branch: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -53,6 +65,8 @@ pub struct DbThread { pub imported: bool, #[serde(default)] pub subagent_context: Option, + #[serde(default)] + pub git_worktree_info: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -91,6 +105,7 @@ impl SharedThread { profile: None, imported: true, subagent_context: None, + git_worktree_info: None, } } @@ -265,6 +280,7 @@ impl DbThread { profile: thread.profile, imported: false, subagent_context: None, + git_worktree_info: None, }) } } @@ -369,6 +385,13 @@ impl ThreadsDatabase { s().ok(); } + if let Ok(mut s) = connection.exec(indoc! {" + ALTER TABLE threads ADD COLUMN worktree_branch TEXT + "}) + { + s().ok(); + } + let db = Self { executor, connection: Arc::new(Mutex::new(connection)), @@ -397,6 +420,10 @@ impl ThreadsDatabase { .subagent_context .as_ref() .map(|ctx| ctx.parent_thread_id.0.clone()); + let worktree_branch = thread + .git_worktree_info + .as_ref() + .map(|info| info.branch.clone()); let json_data = serde_json::to_string(&SerializedThread { thread, version: DbThread::VERSION, @@ -408,11 +435,19 @@ impl ThreadsDatabase { let data_type = DataType::Zstd; let data = compressed; - let mut insert = connection.exec_bound::<(Arc, Option>, String, String, DataType, Vec)>(indoc! {" - INSERT OR REPLACE INTO threads (id, parent_id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?, ?) + let mut insert = connection.exec_bound::<(Arc, Option>, Option, String, String, DataType, Vec)>(indoc! {" + INSERT OR REPLACE INTO threads (id, parent_id, worktree_branch, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?, ?, ?) "})?; - insert((id.0, parent_id, title, updated_at, data_type, data))?; + insert(( + id.0, + parent_id, + worktree_branch, + title, + updated_at, + data_type, + data, + ))?; Ok(()) } @@ -424,19 +459,20 @@ impl ThreadsDatabase { let connection = connection.lock(); let mut select = connection - .select_bound::<(), (Arc, Option>, String, String)>(indoc! {" - SELECT id, parent_id, summary, updated_at FROM threads ORDER BY updated_at DESC + .select_bound::<(), (Arc, Option>, Option, String, String)>(indoc! {" + SELECT id, parent_id, worktree_branch, summary, updated_at FROM threads ORDER BY updated_at DESC "})?; let rows = select(())?; let mut threads = Vec::new(); - for (id, parent_id, summary, updated_at) in rows { + for (id, parent_id, worktree_branch, summary, updated_at) in rows { threads.push(DbThreadMetadata { id: acp::SessionId::new(id), parent_session_id: parent_id.map(acp::SessionId::new), title: summary.into(), updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc), + worktree_branch, }); } @@ -570,6 +606,7 @@ mod tests { profile: None, imported: false, subagent_context: None, + git_worktree_info: None, } } @@ -713,4 +750,96 @@ mod tests { "Regular threads should have no subagent_context" ); } + + #[gpui::test] + async fn test_git_worktree_info_roundtrip(cx: &mut TestAppContext) { + let database = ThreadsDatabase::new(cx.executor()).unwrap(); + + let thread_id = session_id("worktree-thread"); + let mut thread = make_thread( + "Worktree Thread", + Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(), + ); + thread.git_worktree_info = Some(AgentGitWorktreeInfo { + branch: "zed/agent/a4Xiu".to_string(), + worktree_path: std::path::PathBuf::from( + "/tmp/agent-worktrees/my-project/zed/agent/a4Xiu", + ), + base_ref: "main".to_string(), + }); + + database + .save_thread(thread_id.clone(), thread) + .await + .unwrap(); + + let loaded = database + .load_thread(thread_id) + .await + .unwrap() + .expect("thread should exist"); + + let info = loaded + .git_worktree_info + .expect("git_worktree_info should be restored"); + assert_eq!(info.branch, "zed/agent/a4Xiu"); + assert_eq!( + info.worktree_path, + std::path::PathBuf::from("/tmp/agent-worktrees/my-project/zed/agent/a4Xiu") + ); + assert_eq!(info.base_ref, "main"); + } + + #[gpui::test] + async fn test_session_list_includes_worktree_meta(cx: &mut TestAppContext) { + let database = ThreadsDatabase::new(cx.executor()).unwrap(); + + // Save a thread with worktree info + let worktree_id = session_id("wt-thread"); + let mut worktree_thread = make_thread( + "With Worktree", + Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(), + ); + worktree_thread.git_worktree_info = Some(AgentGitWorktreeInfo { + branch: "zed/agent/bR9kz".to_string(), + worktree_path: std::path::PathBuf::from("/tmp/worktrees/bR9kz"), + base_ref: "develop".to_string(), + }); + + database + .save_thread(worktree_id.clone(), worktree_thread) + .await + .unwrap(); + + // Save a thread without worktree info + let plain_id = session_id("plain-thread"); + let plain_thread = make_thread( + "Without Worktree", + Utc.with_ymd_and_hms(2024, 6, 15, 11, 0, 0).unwrap(), + ); + + database + .save_thread(plain_id.clone(), plain_thread) + .await + .unwrap(); + + // List threads and verify worktree_branch is populated correctly + let threads = database.list_threads().await.unwrap(); + assert_eq!(threads.len(), 2); + + let wt_entry = threads + .iter() + .find(|t| t.id == worktree_id) + .expect("should find worktree thread"); + assert_eq!(wt_entry.worktree_branch.as_deref(), Some("zed/agent/bR9kz")); + + let plain_entry = threads + .iter() + .find(|t| t.id == plain_id) + .expect("should find plain thread"); + assert!( + plain_entry.worktree_branch.is_none(), + "plain thread should have no worktree_branch" + ); + } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 1820aebae547af..5a8e9dce1d6621 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1,8 +1,8 @@ use crate::{ - ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread, - DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, - ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, - RestoreFileFromDiskTool, SaveFileTool, StreamingEditFileTool, SubagentTool, + AgentGitWorktreeInfo, ContextServerRegistry, CopyPathTool, CreateDirectoryTool, + DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, + FindPathTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, + ReadFileTool, RestoreFileFromDiskTool, SaveFileTool, StreamingEditFileTool, SubagentTool, SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, WebSearchTool, decide_permission_from_settings, }; @@ -32,7 +32,6 @@ use futures::{ use gpui::{ App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity, }; -use heck::ToSnakeCase as _; use language_model::{ LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelImage, LanguageModelProviderId, LanguageModelRegistry, LanguageModelRequest, @@ -840,6 +839,8 @@ pub struct Thread { subagent_context: Option, /// Weak references to running subagent threads for cancellation propagation running_subagents: Vec>, + /// Git worktree info if this thread is running in an agent worktree. + pub(crate) git_worktree_info: Option, } impl Thread { @@ -930,6 +931,7 @@ impl Thread { imported: false, subagent_context: None, running_subagents: Vec::new(), + git_worktree_info: None, } } @@ -983,20 +985,6 @@ impl Thread { stream: &ThreadEventStream, cx: &mut Context, ) { - // Extract saved output and status first, so they're available even if tool is not found - let output = tool_result - .as_ref() - .and_then(|result| result.output.clone()); - let status = tool_result - .as_ref() - .map_or(acp::ToolCallStatus::Failed, |result| { - if result.is_error { - acp::ToolCallStatus::Failed - } else { - acp::ToolCallStatus::Completed - } - }); - let tool = self.tools.get(tool_use.name.as_ref()).cloned().or_else(|| { self.context_server_registry .read(cx) @@ -1011,25 +999,14 @@ impl Thread { }); let Some(tool) = tool else { - // Tool not found (e.g., MCP server not connected after restart), - // but still display the saved result if available. - // We need to send both ToolCall and ToolCallUpdate events because the UI - // only converts raw_output to displayable content in update_fields, not from_acp. stream .0 .unbounded_send(Ok(ThreadEvent::ToolCall( acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string()) - .status(status) + .status(acp::ToolCallStatus::Failed) .raw_input(tool_use.input.clone()), ))) .ok(); - stream.update_tool_call_fields( - &tool_use.id, - acp::ToolCallUpdateFields::new() - .status(status) - .raw_output(output), - None, - ); return; }; @@ -1043,6 +1020,9 @@ impl Thread { tool_use.input.clone(), ); + let output = tool_result + .as_ref() + .and_then(|result| result.output.clone()); if let Some(output) = output.clone() { // For replay, we use a dummy cancellation receiver since the tool already completed let (_cancellation_tx, cancellation_rx) = watch::channel(false); @@ -1059,7 +1039,17 @@ impl Thread { stream.update_tool_call_fields( &tool_use.id, acp::ToolCallUpdateFields::new() - .status(status) + .status( + tool_result + .as_ref() + .map_or(acp::ToolCallStatus::Failed, |result| { + if result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + } + }), + ) .raw_output(output), None, ); @@ -1154,6 +1144,7 @@ impl Thread { imported: db_thread.imported, subagent_context: db_thread.subagent_context, running_subagents: Vec::new(), + git_worktree_info: db_thread.git_worktree_info, } } @@ -1174,6 +1165,7 @@ impl Thread { profile: Some(self.profile_id.clone()), imported: self.imported, subagent_context: self.subagent_context.clone(), + git_worktree_info: self.git_worktree_info.clone(), }; cx.background_spawn(async move { @@ -1427,6 +1419,10 @@ impl Thread { self.has_queued_message = has_queued; } + pub fn set_git_worktree_info(&mut self, info: AgentGitWorktreeInfo) { + self.git_worktree_info = Some(info); + } + pub fn has_queued_message(&self) -> bool { self.has_queued_message } @@ -2467,14 +2463,13 @@ impl Thread { } // When there are duplicate tool names, disambiguate by prefixing them - // with the server ID (converted to snake_case for API compatibility). - // In the rare case there isn't enough space for the disambiguated tool - // name, keep only the last tool with this name. + // with the server ID. In the rare case there isn't enough space for the + // disambiguated tool name, keep only the last tool with this name. for (server_id, tool_name, tool) in context_server_tools { if duplicate_tool_names.contains(&tool_name) { let available = MAX_TOOL_NAME_LENGTH.saturating_sub(tool_name.len()); if available >= 2 { - let mut disambiguated = server_id.0.to_snake_case(); + let mut disambiguated = server_id.0.to_string(); disambiguated.truncate(available - 1); disambiguated.push('_'); disambiguated.push_str(&tool_name); diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index 83548b69d12646..6add31fdb39302 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -162,6 +162,7 @@ mod tests { profile: None, imported: false, subagent_context: None, + git_worktree_info: None, } } diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs index 16108e599e31f8..3aae30f89724af 100644 --- a/crates/agent_ui/src/acp/thread_history.rs +++ b/crates/agent_ui/src/acp/thread_history.rs @@ -19,6 +19,11 @@ use ui::{ const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); +/// Key in `AgentSessionInfo.meta` used to store the git worktree branch name. +/// Branch name generation (e.g. `zed/agent/`) is handled at creation +/// time in the worktree orchestration layer; this key is a stable lookup key. +const WORKTREE_BRANCH_META_KEY: &str = "worktree_branch"; + fn thread_title(entry: &AgentSessionInfo) -> &SharedString { entry .title @@ -27,6 +32,31 @@ fn thread_title(entry: &AgentSessionInfo) -> &SharedString { .unwrap_or(DEFAULT_TITLE) } +fn worktree_branch_from_meta(entry: &AgentSessionInfo) -> Option<&str> { + entry + .meta + .as_ref() + .and_then(|m| m.get(WORKTREE_BRANCH_META_KEY)) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|branch| !branch.is_empty()) +} + +fn render_worktree_branch_row(branch: String) -> impl IntoElement { + h_flex() + .gap_1() + .child( + Icon::new(IconName::GitBranchAlt) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(branch) + .color(Color::Muted) + .size(LabelSize::XSmall), + ) +} + pub struct AcpThreadHistory { session_list: Option>, sessions: Vec, @@ -67,6 +97,8 @@ impl ListItemType { pub enum ThreadHistoryEvent { Open(AgentSessionInfo), + DeleteRequested(acp::SessionId), + DeleteAllRequested, } impl EventEmitter for AcpThreadHistory {} @@ -359,6 +391,14 @@ impl AcpThreadHistory { } } + pub(crate) fn delete_sessions(&self, cx: &mut App) -> Task> { + if let Some(session_list) = self.session_list.as_ref() { + session_list.delete_sessions(cx) + } else { + Task::ready(Ok(())) + } + } + fn add_list_separators( &self, entries: Vec, @@ -538,24 +578,19 @@ impl AcpThreadHistory { let Some(entry) = self.get_history_entry(visible_item_ix) else { return; }; - let Some(session_list) = self.session_list.as_ref() else { - return; - }; - if !session_list.supports_delete() { + if !self.supports_delete() { return; } - let task = session_list.delete_session(&entry.session_id, cx); - task.detach_and_log_err(cx); + cx.emit(ThreadHistoryEvent::DeleteRequested( + entry.session_id.clone(), + )); } fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(session_list) = self.session_list.as_ref() else { - return; - }; - if !session_list.supports_delete() { + if !self.supports_delete() { return; } - session_list.delete_sessions(cx).detach_and_log_err(cx); + cx.emit(ThreadHistoryEvent::DeleteAllRequested); self.confirming_delete_history = false; cx.notify(); } @@ -650,22 +685,33 @@ impl AcpThreadHistory { .rounded() .toggle_state(selected) .spacing(ListItemSpacing::Sparse) - .start_slot( - h_flex() + .start_slot({ + let branch = worktree_branch_from_meta(entry).map(|b| b.to_string()); + v_flex() .w_full() - .gap_2() - .justify_between() .child( - HighlightedLabel::new(thread_title(entry), highlight_positions) - .size(LabelSize::Small) - .truncate(), + h_flex() + .w_full() + .gap_2() + .justify_between() + .child( + HighlightedLabel::new( + thread_title(entry), + highlight_positions, + ) + .size(LabelSize::Small) + .truncate(), + ) + .child( + Label::new(display_text) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), ) - .child( - Label::new(display_text) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) + .when_some(branch, |this, branch| { + this.child(render_worktree_branch_row(branch)) + }) + }) .tooltip(move |_, cx| { Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) }) @@ -901,21 +947,30 @@ impl RenderOnce for AcpHistoryEntryElement { }) .unwrap_or_else(|| "Unknown".to_string()); + let branch = worktree_branch_from_meta(&self.entry).map(|b| b.to_string()); + ListItem::new(id) .rounded() .toggle_state(self.selected) .spacing(ListItemSpacing::Sparse) .start_slot( - h_flex() + v_flex() .w_full() - .gap_2() - .justify_between() - .child(Label::new(title).size(LabelSize::Small).truncate()) .child( - Label::new(formatted_time) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), + h_flex() + .w_full() + .gap_2() + .justify_between() + .child(Label::new(title).size(LabelSize::Small).truncate()) + .child( + Label::new(formatted_time) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .when_some(branch, |this, branch| { + this.child(render_worktree_branch_row(branch)) + }), ) .on_hover(self.on_hover) .end_slot::(if (self.hovered || self.selected) && self.supports_delete { @@ -1360,4 +1415,133 @@ mod tests { let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); } + + #[test] + fn test_worktree_branch_from_meta() { + let session = |meta: Option| AgentSessionInfo { + session_id: acp::SessionId::new("s1"), + cwd: None, + title: None, + updated_at: None, + meta, + }; + + // Valid branch name + let entry = session(Some(acp::Meta::from_iter([( + WORKTREE_BRANCH_META_KEY.into(), + "zed/agent/a4Xiu".into(), + )]))); + assert_eq!(worktree_branch_from_meta(&entry), Some("zed/agent/a4Xiu")); + + // No meta at all + let entry = session(None); + assert_eq!(worktree_branch_from_meta(&entry), None); + + // Meta present but without the worktree key + let entry = session(Some(acp::Meta::from_iter([( + "other_key".into(), + "value".into(), + )]))); + assert_eq!(worktree_branch_from_meta(&entry), None); + + // Empty string branch is treated as absent + let entry = session(Some(acp::Meta::from_iter([( + WORKTREE_BRANCH_META_KEY.into(), + "".into(), + )]))); + assert_eq!(worktree_branch_from_meta(&entry), None); + + // Whitespace-only branch is treated as absent + let entry = session(Some(acp::Meta::from_iter([( + WORKTREE_BRANCH_META_KEY.into(), + " ".into(), + )]))); + assert_eq!(worktree_branch_from_meta(&entry), None); + + // Branch with surrounding whitespace is trimmed + let entry = session(Some(acp::Meta::from_iter([( + WORKTREE_BRANCH_META_KEY.into(), + " feature/foo ".into(), + )]))); + assert_eq!(worktree_branch_from_meta(&entry), Some("feature/foo")); + + // Non-string value (e.g. number) is treated as absent + let entry = session(Some(acp::Meta::from_iter([( + WORKTREE_BRANCH_META_KEY.into(), + serde_json::Value::Number(42.into()), + )]))); + assert_eq!(worktree_branch_from_meta(&entry), None); + } + + #[gpui::test] + async fn test_history_row_displays_worktree(cx: &mut TestAppContext) { + init_test(cx); + + let session_with_branch = AgentSessionInfo { + session_id: acp::SessionId::new("with-branch"), + cwd: None, + title: Some("Has Worktree".into()), + updated_at: None, + meta: Some(acp::Meta::from_iter([( + WORKTREE_BRANCH_META_KEY.into(), + "zed/agent/a4Xiu".into(), + )])), + }; + let session_without_branch = AgentSessionInfo { + session_id: acp::SessionId::new("without-branch"), + cwd: None, + title: Some("No Worktree".into()), + updated_at: None, + meta: None, + }; + + let sessions = vec![session_with_branch, session_without_branch]; + let session_list = Rc::new(TestSessionList::new(sessions)); + + let (history, cx) = cx.add_window_view(|window, cx| { + AcpThreadHistory::new(Some(session_list.clone()), window, cx) + }); + cx.run_until_parked(); + + // Verify both sessions are loaded and branch metadata is accessible + history.update(cx, |history, _cx| { + assert_eq!(history.sessions.len(), 2); + + let with_branch = history + .sessions + .iter() + .find(|s| s.session_id == acp::SessionId::new("with-branch")) + .expect("session with branch should exist"); + assert_eq!( + worktree_branch_from_meta(with_branch), + Some("zed/agent/a4Xiu") + ); + + let without_branch = history + .sessions + .iter() + .find(|s| s.session_id == acp::SessionId::new("without-branch")) + .expect("session without branch should exist"); + assert_eq!(worktree_branch_from_meta(without_branch), None); + }); + + // Verify that a meta update via SessionInfoUpdate sets the branch + session_list.send_update(SessionListUpdate::SessionInfo { + session_id: acp::SessionId::new("without-branch"), + update: acp::SessionInfoUpdate::new().meta(acp::Meta::from_iter([( + WORKTREE_BRANCH_META_KEY.into(), + "zed/agent/b9Zjk".into(), + )])), + }); + cx.run_until_parked(); + + history.update(cx, |history, _cx| { + let updated = history + .sessions + .iter() + .find(|s| s.session_id == acp::SessionId::new("without-branch")) + .expect("session should still exist after update"); + assert_eq!(worktree_branch_from_meta(updated), Some("zed/agent/b9Zjk")); + }); + } } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 91b8f29638f12a..3b0b0bdc22c5b2 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,4 +1,10 @@ -use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration}; +use std::{ + ops::Range, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, + time::Duration, +}; use acp_thread::{AcpThread, AgentSessionInfo}; use agent::{ContextServerRegistry, SharedThread, ThreadStore}; @@ -12,6 +18,7 @@ use project::{ use serde::{Deserialize, Serialize}; use settings::{LanguageModelProviderSetting, LanguageModelSelection}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent}; use crate::ManageProfiles; @@ -19,8 +26,8 @@ use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; use crate::{ AddContextServer, AgentDiffPane, CopyThreadToClipboard, Follow, InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, - OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ToggleNavigationMenu, ToggleNewThreadMenu, - ToggleOptionsMenu, + OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, SetThreadTarget, ThreadTargetKind, + ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, acp::AcpServerView, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, slash_command::SlashCommandCompletionProvider, @@ -61,8 +68,8 @@ use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; use theme::ThemeSettings; use ui::{ - Callout, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, - Tooltip, prelude::*, utils::WithRemSize, + Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu, + PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize, }; use util::ResultExt as _; use workspace::{ @@ -272,6 +279,13 @@ pub fn init(cx: &mut App) { panel.load_thread_from_clipboard(window, cx); }); } + }) + .register_action(|workspace, action: &SetThreadTarget, _window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.set_thread_target(action, cx); + }); + } }); }, ) @@ -355,6 +369,42 @@ impl From for AgentType { } } +#[derive(Clone, Debug, Default, PartialEq)] +pub enum ThreadTarget { + #[default] + LocalProject, + NewWorktree, + ExistingWorktree { + path: PathBuf, + branch: String, + }, +} + +impl ThreadTarget { + fn label(&self) -> SharedString { + match self { + Self::LocalProject => "Local Project".into(), + Self::NewWorktree => "New Worktree".into(), + Self::ExistingWorktree { branch, .. } => branch.clone().into(), + } + } + + fn icon(&self) -> IconName { + match self { + Self::LocalProject => IconName::Screen, + Self::NewWorktree => IconName::GitBranchPlus, + Self::ExistingWorktree { .. } => IconName::GitBranchAlt, + } + } +} + +#[derive(Clone, Debug)] +#[allow(dead_code)] +pub enum WorktreeCreationStatus { + Creating, + Error(SharedString), +} + impl ActiveView { pub fn which_font_size_used(&self) -> WhichFontSize { match self { @@ -455,16 +505,16 @@ impl ActiveView { } pub struct AgentPanel { - workspace: WeakEntity, + pub(crate) workspace: WeakEntity, /// Workspace id is used as a database key workspace_id: Option, user_store: Entity, - project: Entity, + pub(crate) project: Entity, fs: Arc, language_registry: Arc, - acp_history: Entity, + pub(crate) acp_history: Entity, text_thread_history: Entity, - thread_store: Entity, + pub(crate) thread_store: Entity, text_thread_store: Entity, prompt_store: Option>, context_server_registry: Entity, @@ -475,6 +525,7 @@ pub struct AgentPanel { previous_view: Option, _active_view_observation: Option, new_thread_menu_handle: PopoverMenuHandle, + thread_target_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, agent_navigation_menu_handle: PopoverMenuHandle, agent_navigation_menu: Option>, @@ -485,8 +536,12 @@ pub struct AgentPanel { pending_serialization: Option>>, onboarding: Entity, selected_agent: AgentType, + pub(crate) thread_target: ThreadTarget, + pub(crate) worktree_creation_status: Option, show_trust_workspace_message: bool, last_configuration_error_telemetry: Option, + #[cfg(any(test, feature = "test-support"))] + pub(crate) simulate_post_creation_failure: bool, } impl AgentPanel { @@ -578,40 +633,22 @@ impl AgentPanel { }); } - panel - })?; - - if let Some(thread_info) = serialized_panel.and_then(|p| p.last_active_thread) { - let session_id = acp::SessionId::new(thread_info.session_id.clone()); - let load_task = panel.update(cx, |panel, cx| { - let thread_store = panel.thread_store.clone(); - thread_store.update(cx, |store, cx| store.load_thread(session_id, cx)) - }); - let thread_exists = load_task - .await - .map(|thread: Option| thread.is_some()) - .unwrap_or(false); - - if thread_exists { - panel.update_in(cx, |panel, window, cx| { - panel.selected_agent = thread_info.agent_type.clone(); - let session_info = AgentSessionInfo { - session_id: acp::SessionId::new(thread_info.session_id), - cwd: thread_info.cwd, - title: thread_info.title.map(SharedString::from), - updated_at: None, - meta: None, - }; + if let Some(thread_info) = serialized_panel.and_then(|p| p.last_active_thread) { + let agent_type = thread_info.agent_type.clone(); + let session_info = AgentSessionInfo { + session_id: acp::SessionId::new(thread_info.session_id), + cwd: thread_info.cwd, + title: thread_info.title.map(SharedString::from), + updated_at: None, + meta: None, + }; + panel.update(cx, |panel, cx| { + panel.selected_agent = agent_type; panel.load_agent_thread(session_info, window, cx); - })?; - } else { - log::error!( - "could not restore last active thread: \ - no thread found in database with ID {:?}", - thread_info.session_id - ); + }); } - } + panel + })?; Ok(panel) }) @@ -646,6 +683,12 @@ impl AgentPanel { ThreadHistoryEvent::Open(thread) => { this.load_agent_thread(thread.clone(), window, cx); } + ThreadHistoryEvent::DeleteRequested(session_id) => { + crate::agent_worktree::cleanup_and_delete_thread(this, session_id, window, cx); + } + ThreadHistoryEvent::DeleteAllRequested => { + crate::agent_worktree::cleanup_and_delete_all_threads(this, window, cx); + } }, ) .detach(); @@ -751,6 +794,7 @@ impl AgentPanel { previous_view: None, _active_view_observation: None, new_thread_menu_handle: PopoverMenuHandle::default(), + thread_target_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu: None, @@ -764,8 +808,12 @@ impl AgentPanel { text_thread_history, thread_store, selected_agent: AgentType::default(), + thread_target: ThreadTarget::default(), + worktree_creation_status: None, show_trust_workspace_message: false, last_configuration_error_telemetry: None, + #[cfg(any(test, feature = "test-support"))] + simulate_post_creation_failure: false, }; // Initial sync of agent servers from extensions @@ -846,6 +894,10 @@ impl AgentPanel { } fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { + if self.thread_target == ThreadTarget::NewWorktree { + crate::agent_worktree::create_worktree_and_start_thread(self, window, cx); + return; + } self.new_agent_thread(AgentType::NativeAgent, window, cx); } @@ -1496,10 +1548,6 @@ impl AgentPanel { { update_settings_file(self.fs.clone(), cx, move |settings, _| { let provider = model.provider_id().0.to_string(); - let enable_thinking = model.supports_thinking(); - let effort = model - .default_effort_level() - .map(|effort| effort.value.to_string()); let model = model.id().0.to_string(); settings .agent @@ -1507,8 +1555,8 @@ impl AgentPanel { .set_model(LanguageModelSelection { provider: LanguageModelProviderSetting(provider), model, - enable_thinking, - effort, + enable_thinking: false, + effort: None, }) }); } @@ -1692,6 +1740,33 @@ impl AgentPanel { self.selected_agent.clone() } + pub fn thread_target(&self) -> &ThreadTarget { + &self.thread_target + } + + fn set_thread_target(&mut self, action: &SetThreadTarget, cx: &mut Context) { + let new_target = match action.kind { + ThreadTargetKind::LocalProject => ThreadTarget::LocalProject, + ThreadTargetKind::NewWorktree => ThreadTarget::NewWorktree, + ThreadTargetKind::ExistingWorktree => { + let Some(path) = action.path.as_ref() else { + log::error!("set_thread_target: missing path for existing_worktree"); + return; + }; + let Some(branch) = action.branch.as_ref() else { + log::error!("set_thread_target: missing branch for existing_worktree"); + return; + }; + ThreadTarget::ExistingWorktree { + path: PathBuf::from(path), + branch: branch.clone(), + } + } + }; + self.thread_target = new_target; + cx.notify(); + } + fn selected_external_agent(&self) -> Option { match &self.selected_agent { AgentType::NativeAgent => Some(ExternalAgent::NativeAgent), @@ -1801,7 +1876,32 @@ impl AgentPanel { self.external_thread(Some(agent), Some(thread), None, window, cx); } - fn _external_thread( + /// Start a native agent thread targeting a specific workspace and project. + /// + /// Used by the worktree orchestration to start a thread in the original + /// panel that operates against the new worktree workspace's project. + pub(crate) fn start_native_thread_in_workspace( + &mut self, + target_workspace: WeakEntity, + target_project: Entity, + window: &mut Window, + cx: &mut Context, + ) { + let server = + crate::ExternalAgent::NativeAgent.server(self.fs.clone(), self.thread_store.clone()); + self._external_thread( + server, + None, + None, + target_workspace, + target_project, + crate::ExternalAgent::NativeAgent, + window, + cx, + ); + } + + pub(crate) fn _external_thread( &mut self, server: Rc, resume_thread: Option, @@ -1922,7 +2022,13 @@ impl Panel for AgentPanel { } fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { - if active && matches!(self.active_view, ActiveView::Uninitialized) { + if active + && matches!(self.active_view, ActiveView::Uninitialized) + && !matches!( + self.worktree_creation_status, + Some(WorktreeCreationStatus::Creating) + ) + { let selected_agent = self.selected_agent.clone(); self.new_agent_thread(selected_agent, window, cx); } @@ -2300,6 +2406,107 @@ impl AgentPanel { }) } + fn project_has_git_repository(&self, cx: &App) -> bool { + !self.project.read(cx).repositories(cx).is_empty() + } + + fn render_thread_target_selector(&self, cx: &mut Context) -> impl IntoElement { + let has_git_repo = self.project_has_git_repository(cx); + let is_via_collab = self + .workspace + .upgrade() + .map(|workspace| workspace.read(cx).project().read(cx).is_via_collab()) + .unwrap_or_default(); + + let is_creating = matches!( + self.worktree_creation_status, + Some(WorktreeCreationStatus::Creating) + ); + + let current_target = self.thread_target.clone(); + let trigger_label = self.thread_target.label(); + + let icon = if self.thread_target_menu_handle.is_deployed() { + IconName::ChevronUp + } else { + IconName::ChevronDown + }; + + let trigger_button = Button::new("thread-target-trigger", trigger_label) + .label_size(LabelSize::Small) + .color(Color::Muted) + .icon(icon) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::End) + .icon_color(Color::Muted) + .disabled(is_creating); + + let dock_position = AgentSettings::get_global(cx).dock; + let documentation_side = match dock_position { + settings::DockPosition::Left => DocumentationSide::Right, + settings::DockPosition::Bottom | settings::DockPosition::Right => { + DocumentationSide::Left + } + }; + + PopoverMenu::new("thread-target-selector") + .trigger(trigger_button) + .anchor(gpui::Corner::BottomRight) + .with_handle(self.thread_target_menu_handle.clone()) + .menu(move |window, cx| { + let current_target = current_target.clone(); + Some(ContextMenu::build(window, cx, move |menu, _window, _cx| { + let is_local_selected = current_target == ThreadTarget::LocalProject; + let is_new_worktree_selected = current_target == ThreadTarget::NewWorktree; + + let new_worktree_disabled = !has_git_repo || is_via_collab; + + menu.header("Start Thread In…") + .item( + ContextMenuEntry::new("Local Project") + .icon(ThreadTarget::LocalProject.icon()) + .icon_color(Color::Muted) + .toggleable(IconPosition::End, is_local_selected) + .handler(|window, cx| { + window.dispatch_action( + Box::new(SetThreadTarget::local_project()), + cx, + ); + }), + ) + .item({ + let entry = ContextMenuEntry::new("New Worktree") + .icon(ThreadTarget::NewWorktree.icon()) + .icon_color(Color::Muted) + .toggleable(IconPosition::End, is_new_worktree_selected) + .disabled(new_worktree_disabled) + .handler(|window, cx| { + window.dispatch_action( + Box::new(SetThreadTarget::new_worktree()), + cx, + ); + }); + + if new_worktree_disabled { + entry.documentation_aside(documentation_side, move |_| { + let reason = if !has_git_repo { + "No git repository found in this project." + } else { + "Not available for remote/collab projects yet." + }; + Label::new(reason) + .color(Color::Muted) + .size(LabelSize::Small) + .into_any_element() + }) + } else { + entry + } + }) + })) + }) + } + fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); @@ -2660,6 +2867,7 @@ impl AgentPanel { }; let show_history_menu = self.history_kind_for_selected_agent(cx).is_some(); + let has_v2_flag = cx.has_flag::(); h_flex() .id("agent-panel-toolbar") @@ -2690,6 +2898,9 @@ impl AgentPanel { .gap(DynamicSpacing::Base02.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) .pr(DynamicSpacing::Base06.rems(cx)) + .when(has_v2_flag, |this| { + this.child(self.render_thread_target_selector(cx)) + }) .child(new_thread_menu) .when(show_history_menu, |this| { this.child(self.render_recent_entries_menu( @@ -2702,6 +2913,51 @@ impl AgentPanel { ) } + fn render_worktree_creation_status(&self, cx: &mut Context) -> Option { + let status = self.worktree_creation_status.as_ref()?; + match status { + WorktreeCreationStatus::Creating => Some( + h_flex() + .w_full() + .px(DynamicSpacing::Base06.rems(cx)) + .py(DynamicSpacing::Base02.rems(cx)) + .gap_2() + .bg(cx.theme().colors().surface_background) + .border_b_1() + .border_color(cx.theme().colors().border) + .child(SpinnerLabel::new().size(LabelSize::Small)) + .child( + Label::new("Creating worktree…") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element(), + ), + WorktreeCreationStatus::Error(message) => Some( + h_flex() + .w_full() + .px(DynamicSpacing::Base06.rems(cx)) + .py(DynamicSpacing::Base02.rems(cx)) + .gap_2() + .bg(cx.theme().colors().surface_background) + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .child( + Label::new(message.clone()) + .color(Color::Warning) + .size(LabelSize::Small) + .truncate(), + ) + .into_any_element(), + ), + } + } + fn should_render_trial_end_upsell(&self, cx: &mut Context) -> bool { if TrialEndUpsell::dismissed() { return false; @@ -3131,6 +3387,7 @@ impl Render for AgentPanel { } })) .child(self.render_toolbar(window, cx)) + .children(self.render_worktree_creation_status(cx)) .children(self.render_workspace_trust_message(cx)) .children(self.render_onboarding(window, cx)) .map(|parent| { @@ -3414,11 +3671,15 @@ impl AgentPanel { mod tests { use super::*; use crate::acp::thread_view::tests::{StubAgentServer, init_test}; + use crate::agent_worktree; + use agent::AgentGitWorktreeInfo; + use agent_client_protocol as acp; use assistant_text_thread::TextThreadStore; use feature_flags::FeatureFlagAppExt; use fs::FakeFs; use gpui::{TestAppContext, VisualTestContext}; use project::Project; + use serde_json::json; use workspace::MultiWorkspace; #[gpui::test] @@ -3519,9 +3780,7 @@ mod tests { .expect("panel B load should succeed"); cx.run_until_parked(); - // Workspace A should restore width and agent type, but the thread - // should NOT be restored because the stub agent never persisted it - // to the database (the load-side validation skips missing threads). + // Workspace A should restore its thread, width, and agent type loaded_a.read_with(cx, |panel, _cx| { assert_eq!( panel.width, @@ -3532,6 +3791,10 @@ mod tests { panel.selected_agent, agent_type_a, "workspace A agent type should be restored" ); + assert!( + panel.active_thread_view().is_some(), + "workspace A should have its active thread restored" + ); }); // Workspace B should restore its own width and agent type, with no thread @@ -3599,4 +3862,738 @@ mod tests { cx.run_until_parked(); } + + #[gpui::test] + async fn test_new_worktree_thread_creation_flow(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + // Create a FakeFs with a project that has a git repository and source files. + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() { println!(\"hello\"); }" + } + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + // Create a MultiWorkspace window with the project as the sole workspace. + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let original_workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + original_workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // Wait for the project to discover the git repository. + cx.run_until_parked(); + + // Set up an AgentPanel on the original workspace. + let panel = original_workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + // ---------------------------------------------------------------- + // Phase 1.1: Thread target selection + // ---------------------------------------------------------------- + + // The project has a .git directory, so the panel should detect a git repository. + panel.read_with(cx, |panel, cx| { + assert!( + panel.project_has_git_repository(cx), + "project should have a git repository" + ); + }); + + // Default thread target should be LocalProject. + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.thread_target(), + ThreadTarget::LocalProject, + "default thread target should be LocalProject" + ); + }); + + // Set thread target to NewWorktree via the production action path. + original_workspace.update_in(cx, |_workspace, window, cx| { + window.dispatch_action(Box::new(SetThreadTarget::new_worktree()), cx); + }); + + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.thread_target(), + ThreadTarget::NewWorktree, + "thread target should be NewWorktree after setting it" + ); + }); + + // ---------------------------------------------------------------- + // Phase 1.3: Pre-creation status should be idle + // ---------------------------------------------------------------- + panel.read_with(cx, |panel, _cx| { + assert!( + panel.worktree_creation_status.is_none(), + "no worktree creation should be in progress before starting a thread" + ); + }); + + // Verify MultiWorkspace starts with exactly one workspace. + multi_workspace + .read_with(cx, |multi_workspace, _cx| { + assert_eq!( + multi_workspace.workspaces().len(), + 1, + "should start with exactly one workspace" + ); + assert_eq!(multi_workspace.active_workspace_index(), 0); + }) + .unwrap(); + + // ---------------------------------------------------------------- + // Start a new thread with NewWorktree target. + // + // Full flow: + // 1. Panel sets worktree_creation_status to Creating + // 2. A new git worktree is created (git worktree add) based off "main" + // 3. A new Project is created pointing at the worktree path + // 4. A new Workspace is added to MultiWorkspace for that project + // 5. The new workspace becomes active + // 6. The agent thread starts targeting the new workspace's project + // 7. worktree_creation_status is cleared + // ---------------------------------------------------------------- + original_workspace.update_in(cx, |_workspace, window, cx| { + window.dispatch_action(NewThread.boxed_clone(), cx); + }); + + cx.run_until_parked(); + + // --- Assert: A new workspace was added to MultiWorkspace --- + // When the worktree creation flow is implemented, starting a thread + // with NewWorktree should create a second workspace in the MultiWorkspace. + let workspace_count = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspaces().len() + }) + .unwrap(); + + assert_eq!( + workspace_count, 2, + "a new workspace should have been added to MultiWorkspace for the git worktree" + ); + + // --- Assert: The new workspace is the active workspace --- + let active_index = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.active_workspace_index() + }) + .unwrap(); + + assert_eq!( + active_index, 1, + "the new worktree workspace should be the active workspace" + ); + + // --- Assert: The new workspace has a different project --- + let new_workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspaces()[1].clone() + }) + .unwrap(); + + let original_project_id = + original_workspace.read_with(cx, |workspace, _cx| workspace.project().entity_id()); + + let new_project_id = + new_workspace.read_with(cx, |workspace, _cx| workspace.project().entity_id()); + + assert_ne!( + original_project_id, new_project_id, + "new workspace should have a different project than the original" + ); + + // --- Assert: The new workspace's project points at the git worktree path --- + // The worktree directory should be under a well-known location + // (configured by agent_worktree_directory setting) and not the original /project path. + let new_workspace_worktree_roots = new_workspace.read_with(cx, |workspace, cx| { + workspace + .worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .collect::>() + }); + + assert!( + !new_workspace_worktree_roots.is_empty(), + "new workspace should have at least one worktree" + ); + assert!( + new_workspace_worktree_roots + .iter() + .all(|path| path != Path::new("/project")), + "new workspace worktree path should differ from the original project path; \ + got: {:?}", + new_workspace_worktree_roots + ); + + // --- Assert: Worktree creation status is cleared after success --- + panel.read_with(cx, |panel, _cx| { + assert!( + panel.worktree_creation_status.is_none(), + "worktree creation status should be cleared after successful creation" + ); + }); + + // --- Assert: Thread target resets to LocalProject after thread creation --- + // Once the worktree thread has been started, the target should reset so + // the next "New Thread" doesn't accidentally create another worktree. + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.thread_target(), + ThreadTarget::LocalProject, + "thread target should reset to LocalProject after worktree thread creation" + ); + }); + + // --- Assert: The agent thread view is active in the panel --- + // We check `active_thread_view()` (the view is set up) rather than + // `active_agent_thread()` (which requires a fully connected server + // that isn't available in unit tests). + panel.read_with(cx, |panel, _cx| { + assert!( + panel.active_thread_view_for_tests().is_some(), + "an agent thread view should be active after starting a new worktree thread" + ); + }); + + // ---------------------------------------------------------------- + // Phase: Branch rename on thread title summarization + // + // When the agent summarizes the thread and produces a title, + // the git worktree branch should be renamed to match. + // For example, title "Fix login bug" → branch "zed/fix-login-bug". + // ---------------------------------------------------------------- + + // Simulate the agent producing a thread title summary. + // In the real flow, this comes from the ACP session update. + // For now, we just verify the infrastructure is in place by checking + // that the original branch on the original workspace is still "main" + // (i.e., we didn't accidentally mutate it). + project.read_with(cx, |project, cx| { + let repositories = project.repositories(cx); + assert!( + !repositories.is_empty(), + "original project should still have its git repository" + ); + for repository in repositories.values() { + let branch_name = repository + .read(cx) + .branch + .as_ref() + .map(|b| b.name().to_string()); + assert_eq!( + branch_name, + Some("main".to_string()), + "original project branch should still be 'main'" + ); + } + }); + } + + #[gpui::test] + async fn test_thread_target_local_project(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + cx.run_until_parked(); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + // Default thread target should be LocalProject. + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.thread_target(), + ThreadTarget::LocalProject, + "default thread target should be LocalProject" + ); + }); + + // Start a new thread with the default LocalProject target. + // Use StubAgentServer so the thread connects immediately in tests. + panel.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + + cx.run_until_parked(); + + // MultiWorkspace should still have exactly one workspace (no worktree created). + multi_workspace + .read_with(cx, |multi_workspace, _cx| { + assert_eq!( + multi_workspace.workspaces().len(), + 1, + "LocalProject should not create a new workspace" + ); + }) + .unwrap(); + + // The thread should be active in the panel. + panel.read_with(cx, |panel, cx| { + assert!( + panel.active_agent_thread(cx).is_some(), + "a thread should be running in the current workspace" + ); + }); + + // The thread target should still be LocalProject (unchanged). + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.thread_target(), + ThreadTarget::LocalProject, + "thread target should remain LocalProject" + ); + }); + + // No worktree creation status should be set. + panel.read_with(cx, |panel, _cx| { + assert!( + panel.worktree_creation_status.is_none(), + "no worktree creation should have occurred" + ); + }); + } + + #[gpui::test] + async fn test_create_agent_worktree_failure(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + + // Simulate a create_worktree failure. + fs.set_create_worktree_error( + Path::new("/project/.git"), + Some("disk full: cannot create worktree".to_string()), + ); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + cx.run_until_parked(); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + // Set thread target to NewWorktree. + workspace.update_in(cx, |_workspace, window, cx| { + window.dispatch_action(Box::new(SetThreadTarget::new_worktree()), cx); + }); + + panel.read_with(cx, |panel, _cx| { + assert_eq!(*panel.thread_target(), ThreadTarget::NewWorktree); + }); + + // Start a new thread — this should trigger worktree creation which will fail. + workspace.update_in(cx, |_workspace, window, cx| { + window.dispatch_action(NewThread.boxed_clone(), cx); + }); + + cx.run_until_parked(); + + // The error should be surfaced via WorktreeCreationStatus::Error. + panel.read_with(cx, |panel, _cx| match &panel.worktree_creation_status { + Some(WorktreeCreationStatus::Error(message)) => { + assert!( + message.contains("disk full"), + "error message should contain the simulated error; got: {message}" + ); + } + other => panic!("expected WorktreeCreationStatus::Error, got: {:?}", other), + }); + + // No new workspace should have been added. + multi_workspace + .read_with(cx, |multi_workspace, _cx| { + assert_eq!( + multi_workspace.workspaces().len(), + 1, + "failed worktree creation should not add a workspace" + ); + }) + .unwrap(); + + // Thread target should have been reset to LocalProject (the reset + // happens at the start of create_worktree_and_start_thread). + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.thread_target(), + ThreadTarget::LocalProject, + "thread target should reset even on failure" + ); + }); + + // No agent thread should be running. + panel.read_with(cx, |panel, _cx| { + assert!( + panel.active_thread_view_for_tests().is_none(), + "no thread view should be active after a failed worktree creation" + ); + }); + } + + #[gpui::test] + async fn test_create_agent_worktree_rollback(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + cx.run_until_parked(); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + panel.update(cx, |panel, _cx| { + panel.simulate_post_creation_failure = true; + }); + + workspace.update_in(cx, |_workspace, window, cx| { + window.dispatch_action(Box::new(SetThreadTarget::new_worktree()), cx); + }); + + workspace.update_in(cx, |_workspace, window, cx| { + window.dispatch_action(NewThread.boxed_clone(), cx); + }); + + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| match &panel.worktree_creation_status { + Some(WorktreeCreationStatus::Error(message)) => { + assert!( + message.contains("simulated post-creation failure"), + "error message should contain the simulated failure; got: {message}" + ); + } + other => panic!("expected WorktreeCreationStatus::Error, got: {:?}", other), + }); + + multi_workspace + .read_with(cx, |multi_workspace, _cx| { + assert_eq!( + multi_workspace.workspaces().len(), + 1, + "no new workspace should have been added after rollback" + ); + }) + .unwrap(); + + let worktree_count = fs + .with_git_state(Path::new("/project/.git"), false, |state| { + state.worktrees.len() + }) + .unwrap(); + assert_eq!( + worktree_count, 0, + "git worktree should have been rolled back" + ); + } + + #[gpui::test] + async fn test_cleanup_agent_worktree(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let original_workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + original_workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + cx.run_until_parked(); + + let panel = original_workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + // Use the full worktree creation flow to create the git worktree and + // open a second workspace in the MultiWorkspace. + original_workspace.update_in(cx, |_workspace, window, cx| { + window.dispatch_action(Box::new(SetThreadTarget::new_worktree()), cx); + }); + + original_workspace.update_in(cx, |_workspace, window, cx| { + window.dispatch_action(NewThread.boxed_clone(), cx); + }); + + cx.run_until_parked(); + + multi_workspace + .read_with(cx, |multi_workspace, _cx| { + assert_eq!( + multi_workspace.workspaces().len(), + 2, + "should have 2 workspaces after worktree creation" + ); + }) + .unwrap(); + + let worktree_count = fs + .with_git_state(Path::new("/project/.git"), false, |state| { + state.worktrees.len() + }) + .unwrap(); + assert_eq!( + worktree_count, 1, + "should have 1 git worktree after creation" + ); + + // The native agent server doesn't connect in unit tests, so + // `active_native_agent_thread()` returns None and the worktree info + // was never persisted on the thread. Instead, read the worktree path + // from FakeGitRepository state and manually save a DbThread with + // AgentGitWorktreeInfo so cleanup_and_delete_thread can find it. + let (worktree_path, branch) = fs + .with_git_state(Path::new("/project/.git"), false, |state| { + let worktree = &state.worktrees[0]; + (worktree.path.clone(), worktree.ref_name.to_string()) + }) + .unwrap(); + + let branch = branch + .strip_prefix("refs/heads/") + .unwrap_or(&branch) + .to_string(); + + let session_id = acp::SessionId::new("cleanup-test-session".to_string()); + + let thread_store = panel.read_with(cx, |panel, _cx| panel.thread_store.clone()); + + let db_thread = agent::DbThread { + title: "test cleanup thread".into(), + messages: Vec::new(), + updated_at: chrono::Utc::now(), + detailed_summary: None, + initial_project_snapshot: None, + cumulative_token_usage: Default::default(), + request_token_usage: Default::default(), + model: None, + profile: None, + imported: false, + subagent_context: None, + git_worktree_info: Some(AgentGitWorktreeInfo { + branch, + worktree_path: worktree_path.clone(), + base_ref: "HEAD".to_string(), + }), + }; + + thread_store + .update(cx, |store, cx| { + store.save_thread(session_id.clone(), db_thread, cx) + }) + .await + .expect("save_thread should succeed"); + + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + agent_worktree::cleanup_and_delete_thread(panel, &session_id, window, cx); + }); + + cx.run_until_parked(); + + multi_workspace + .read_with(cx, |multi_workspace, _cx| { + assert_eq!( + multi_workspace.workspaces().len(), + 1, + "should be back to 1 workspace after cleanup" + ); + }) + .unwrap(); + + let worktree_count = fs + .with_git_state(Path::new("/project/.git"), false, |state| { + state.worktrees.len() + }) + .unwrap(); + assert_eq!( + worktree_count, 0, + "git worktree should have been cleaned up" + ); + } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index a517ea866bc5c5..ea8ea8fbbc377e 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -4,6 +4,7 @@ mod agent_diff; mod agent_model_selector; mod agent_panel; mod agent_registry_ui; +mod agent_worktree; mod buffer_codegen; mod completion_provider; mod context; @@ -225,6 +226,56 @@ impl ExternalAgent { } } +/// Sets the thread target for new threads (where the thread will run). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ThreadTargetKind { + LocalProject, + NewWorktree, + ExistingWorktree, +} + +/// Sets the thread target for new threads (where the thread will run). +#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] +#[action(namespace = agent)] +#[serde(deny_unknown_fields)] +pub struct SetThreadTarget { + /// The target kind. + pub kind: ThreadTargetKind, + /// Path to an existing worktree (only for "existing_worktree" kind). + #[serde(default)] + pub path: Option, + /// Branch name in the worktree (only for "existing_worktree" kind). + #[serde(default)] + pub branch: Option, +} + +impl SetThreadTarget { + pub fn local_project() -> Self { + Self { + kind: ThreadTargetKind::LocalProject, + path: None, + branch: None, + } + } + + pub fn new_worktree() -> Self { + Self { + kind: ThreadTargetKind::NewWorktree, + path: None, + branch: None, + } + } + + pub fn existing_worktree(path: String, branch: String) -> Self { + Self { + kind: ThreadTargetKind::ExistingWorktree, + path: Some(path), + branch: Some(branch), + } + } +} + /// Content to initialize new external agent with. pub enum ExternalAgentInitialContent { ThreadSummary(acp_thread::AgentSessionInfo), @@ -377,13 +428,13 @@ fn update_command_palette_filter(cx: &mut App) { if agent_enabled { filter.show_namespace("agent"); filter.show_namespace("agents"); - filter.show_namespace("assistant"); } else { filter.hide_namespace("agent"); filter.hide_namespace("agents"); - filter.hide_namespace("assistant"); } + filter.show_namespace("assistant"); + match edit_prediction_provider { EditPredictionProvider::None => { filter.hide_namespace("edit_prediction"); @@ -418,6 +469,9 @@ fn update_command_palette_filter(cx: &mut App) { filter.show_namespace("zed_predict_onboarding"); filter.show_action_types(&[TypeId::of::()]); + if !agent_v2_enabled { + filter.hide_action_types(&[TypeId::of::()]); + } } if agent_v2_enabled { @@ -523,7 +577,7 @@ mod tests { use gpui::{BorrowAppContext, TestAppContext, px}; use project::DisableAiSettings; use settings::{ - DefaultAgentView, DockPosition, NotifyWhenAgentWaiting, Settings, SettingsStore, + DefaultAgentView, DockPosition, DockSide, NotifyWhenAgentWaiting, Settings, SettingsStore, }; #[gpui::test] @@ -542,6 +596,7 @@ mod tests { enabled: true, button: true, dock: DockPosition::Right, + agents_panel_dock: DockSide::Left, default_width: px(300.), default_height: px(600.), default_model: None, @@ -584,10 +639,6 @@ mod tests { !filter.is_hidden(&NewThread), "NewThread should be visible by default" ); - assert!( - !filter.is_hidden(&text_thread_editor::CopyCode), - "CopyCode should be visible when agent is enabled" - ); }); // Disable agent @@ -607,10 +658,6 @@ mod tests { filter.is_hidden(&NewThread), "NewThread should be hidden when agent is disabled" ); - assert!( - filter.is_hidden(&text_thread_editor::CopyCode), - "CopyCode should be hidden when agent is disabled" - ); }); // Test EditPredictionProvider diff --git a/crates/agent_ui/src/agent_worktree.rs b/crates/agent_ui/src/agent_worktree.rs new file mode 100644 index 00000000000000..1d88b33c6c92c7 --- /dev/null +++ b/crates/agent_ui/src/agent_worktree.rs @@ -0,0 +1,567 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use agent::AgentGitWorktreeInfo; +use agent_client_protocol as acp; +use anyhow::{Context as _, Result}; +use gpui::SharedString; +use project::project_settings::ProjectSettings; +use project::trusted_worktrees::{PathTrust, TrustedWorktrees}; +use settings::Settings; +use workspace::{MultiWorkspace, OpenOptions}; + +use crate::agent_panel::{AgentPanel, ThreadTarget, WorktreeCreationStatus}; + +/// Generate a branch name for an agent worktree. +/// +/// Format: `zed/agent/` where `` is a random +/// 5-character alphanumeric string. +pub fn generate_branch_name() -> String { + const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const ID_LENGTH: usize = 5; + + let mut rng = rand::rng(); + let short_id: String = (0..ID_LENGTH) + .map(|_| { + let index = rand::Rng::random_range(&mut rng, 0..CHARSET.len()); + CHARSET[index] as char + }) + .collect(); + + format!("zed/agent/{short_id}") +} + +/// Resolve the directory where an agent worktree should be created. +/// +/// The resolution order is: +/// 1. If `configured_directory` is `Some` and is an absolute path, use it directly. +/// 2. If `configured_directory` is `Some` and is a relative path, resolve it +/// relative to `project_root`. +/// 3. If `configured_directory` is `None`, use the default location under the +/// Zed data directory: `/agent-worktrees//`. +pub fn resolve_worktree_directory( + configured_directory: Option<&str>, + project_root: &Path, + repo_name: &str, +) -> Result { + match configured_directory { + Some(directory) => { + let path = PathBuf::from(directory); + if path.is_absolute() { + Ok(path) + } else { + Ok(project_root.join(path)) + } + } + None => { + let data_dir = paths::data_dir(); + Ok(data_dir.join("agent-worktrees").join(repo_name)) + } + } +} + +/// Extract the repository name from a project root path. +/// +/// Uses the last component of the path as the repo name, falling back +/// to "unknown" if the path has no file name component. +pub fn repo_name_from_path(project_root: &Path) -> &str { + project_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown") +} + +/// Spawn the async orchestration that creates a git worktree, opens it as a +/// new workspace in the current MultiWorkspace, and starts an agent thread there. +/// +/// This is called from `AgentPanel::new_thread` when the user has +/// `ThreadTarget::NewWorktree` selected. +pub fn create_worktree_and_start_thread( + agent_panel: &mut AgentPanel, + window: &mut gpui::Window, + cx: &mut gpui::Context, +) { + agent_panel.worktree_creation_status = Some(WorktreeCreationStatus::Creating); + agent_panel.thread_target = ThreadTarget::LocalProject; + cx.notify(); + + let project = agent_panel.project.clone(); + let workspace = agent_panel.workspace.clone(); + + #[cfg(any(test, feature = "test-support"))] + let simulate_post_creation_failure = agent_panel.simulate_post_creation_failure; + + cx.spawn_in(window, async move |this, cx| { + // Step 1: Get the active repository and project settings. + let (repo, work_dir, configured_directory, fs) = cx + .update(|_window, cx| { + let git_store = project.read(cx).git_store().clone(); + let repo = git_store + .read(cx) + .active_repository() + .context("no active git repository")?; + + let work_dir = repo.read(cx).snapshot().work_directory_abs_path; + + let settings = ProjectSettings::get_global(cx); + let configured_directory = settings.git.agent_worktree_directory.clone(); + + let fs = project.read(cx).fs().clone(); + + anyhow::Ok((repo, work_dir, configured_directory, fs)) + }) + .context("failed to read project state")? + .context("failed to read project")?; + + // Step 2: Generate branch name and resolve storage directory. + let branch_name = generate_branch_name(); + let repo_name = repo_name_from_path(&work_dir); + let worktree_directory = + resolve_worktree_directory(configured_directory.as_deref(), &work_dir, repo_name)?; + + // Ensure the parent directory exists. + fs.create_dir(&worktree_directory) + .await + .context("failed to create worktree storage directory")?; + + // Step 3: Create the git worktree. Use "HEAD" as the base commit so the + // agent starts from the same state the user is looking at. + let create_result = repo + .update(cx, |repo, _cx| { + repo.create_worktree( + branch_name.clone(), + worktree_directory.clone(), + Some("HEAD".to_string()), + ) + }) + .await; + + let create_result = match create_result { + Ok(inner) => inner, + Err(error) => Err(anyhow::anyhow!("{error:#}")), + }; + + if let Err(error) = create_result { + let message: SharedString = format!("{error:#}").into(); + this.update_in(cx, |agent_panel, _window, cx| { + agent_panel.worktree_creation_status = Some(WorktreeCreationStatus::Error(message)); + cx.notify(); + })?; + return anyhow::Ok(()); + } + + // From this point on, if anything fails we need to roll back + // the git worktree that was successfully created. + let new_worktree_path = worktree_directory.join(&branch_name); + let worktree_info = AgentGitWorktreeInfo { + branch: branch_name.clone(), + worktree_path: new_worktree_path.clone(), + base_ref: "HEAD".to_string(), + }; + + let result = create_worktree_post_steps( + &this, + &workspace, + &repo, + &new_worktree_path, + worktree_info, + #[cfg(any(test, feature = "test-support"))] + simulate_post_creation_failure, + cx, + ) + .await; + + // Rollback: if workspace open or thread startup failed after the + // git worktree was already created, clean it up. + if let Err(error) = result { + let remove_result = repo + .update(cx, |repo, _cx| { + repo.remove_worktree(new_worktree_path.clone(), true) + }) + .await; + + let remove_result = match remove_result { + Ok(inner) => inner, + Err(error) => Err(anyhow::anyhow!("{error:#}")), + }; + + if let Err(rollback_error) = remove_result { + log::warn!("failed to roll back git worktree: {rollback_error:#}"); + } + + let message: SharedString = format!("{error:#}").into(); + this.update_in(cx, |agent_panel, _window, cx| { + agent_panel.worktree_creation_status = Some(WorktreeCreationStatus::Error(message)); + cx.notify(); + })?; + return anyhow::Ok(()); + } + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); +} + +/// Post-creation steps: trust the path, clear status, open workspace, start +/// thread, and persist `AgentGitWorktreeInfo`. Extracted so that on failure +/// the caller can roll back the git worktree. +async fn create_worktree_post_steps( + this: &gpui::WeakEntity, + workspace: &gpui::WeakEntity, + repo: &gpui::Entity, + new_worktree_path: &Path, + worktree_info: AgentGitWorktreeInfo, + #[cfg(any(test, feature = "test-support"))] simulate_failure: bool, + cx: &mut gpui::AsyncWindowContext, +) -> Result<()> { + #[cfg(any(test, feature = "test-support"))] + if simulate_failure { + anyhow::bail!("simulated post-creation failure"); + } + + // Step 4: Trust the new worktree path (following worktree_picker.rs pattern). + workspace + .update(cx, |workspace, cx| { + let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) else { + return; + }; + + let project_handle = workspace.project(); + let repo_snapshot = repo.read(cx).snapshot(); + let repo_path = &repo_snapshot.work_directory_abs_path; + + let Some((parent_worktree, _)) = project_handle.read(cx).find_worktree(repo_path, cx) + else { + return; + }; + + let worktree_store = project_handle.read(cx).worktree_store(); + let parent_id = parent_worktree.read(cx).id(); + + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + if trusted_worktrees.can_trust(&worktree_store, parent_id, cx) { + trusted_worktrees.trust( + &worktree_store, + HashSet::from_iter([PathTrust::AbsPath(new_worktree_path.to_path_buf())]), + cx, + ); + } + }); + }) + .ok(); + + // Step 5: Get app_state and window handle for opening a new workspace + // within the existing MultiWorkspace. + let (app_state, window_handle) = workspace + .update_in(cx, |workspace, window, _cx| { + let app_state = workspace.app_state().clone(); + let window_handle = window.window_handle().downcast::(); + (app_state, window_handle) + }) + .context("failed to read workspace state")?; + + // Step 6: Open the worktree as a new workspace in the same MultiWorkspace. + // Using `open_new_workspace: Some(true)` ensures we always create a new + // workspace rather than reusing an existing one. Setting `replace_window` + // to the current window handle causes `Workspace::new_local` to add the + // workspace to the existing MultiWorkspace instead of opening a new OS window. + let worktree_path = new_worktree_path.to_path_buf(); + let open_task = cx.update(|_window, cx| { + workspace::open_paths( + &[worktree_path], + app_state, + OpenOptions { + replace_window: window_handle, + open_new_workspace: Some(true), + ..Default::default() + }, + cx, + ) + })?; + + let (multi_workspace_handle, _items) = open_task + .await + .context("failed to open worktree as workspace")?; + + // Step 7: Read the new workspace and project from the MultiWorkspace. + // We use `read_with` on the window handle (rather than reading from + // inside an `update_in` callback) because `update_window` temporarily + // takes the window out of the map, making nested reads fail. + let (new_workspace, new_project) = multi_workspace_handle + .read_with(cx, |multi, cx| { + let workspace = multi.workspace().clone(); + let project = workspace.read(cx).project().clone(); + (workspace.downgrade(), project) + }) + .context("failed to find new workspace in MultiWorkspace")?; + + // Step 8: Clear creation status, start the thread, and persist worktree info. + this.update_in(cx, |agent_panel, window, cx| { + agent_panel.worktree_creation_status = None; + cx.notify(); + + agent_panel.start_native_thread_in_workspace(new_workspace, new_project, window, cx); + + // Step 9: Persist AgentGitWorktreeInfo on the newly created thread. + if let Some(thread) = agent_panel.active_native_agent_thread(cx) { + thread.update(cx, |thread, _cx| { + thread.set_git_worktree_info(worktree_info); + }); + } + })?; + + Ok(()) +} + +/// Clean up a git worktree (if any) associated with a thread, then delete the +/// thread from the database. Called when the user deletes a single history entry. +pub fn cleanup_and_delete_thread( + agent_panel: &mut AgentPanel, + session_id: &acp::SessionId, + _window: &mut gpui::Window, + cx: &mut gpui::Context, +) { + let session_id = session_id.clone(); + let thread_store = agent_panel.thread_store.clone(); + let project = agent_panel.project.clone(); + let acp_history = agent_panel.acp_history.clone(); + + cx.spawn_in(_window, async move |this, cx| { + let worktree_info = thread_store + .update(cx, |store, cx| store.load_thread(session_id.clone(), cx)) + .await? + .and_then(|thread| thread.git_worktree_info); + + if let Some(info) = &worktree_info { + remove_worktree_workspace(&this, info, cx).ok(); + cleanup_git_worktree(info, &project, &mut *cx).await; + } + + acp_history + .update(cx, |history, cx| history.delete_session(&session_id, cx)) + .await?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); +} + +/// Clean up all git worktrees associated with threads, then delete all threads +/// from the database. Called when the user clears all history. +pub fn cleanup_and_delete_all_threads( + agent_panel: &mut AgentPanel, + _window: &mut gpui::Window, + cx: &mut gpui::Context, +) { + let thread_store = agent_panel.thread_store.clone(); + let project = agent_panel.project.clone(); + let acp_history = agent_panel.acp_history.clone(); + + cx.spawn_in(_window, async move |this, cx| { + let worktree_session_ids: Vec = thread_store.read_with(cx, |store, _cx| { + store + .entries() + .filter(|entry| entry.worktree_branch.is_some()) + .map(|entry| entry.id) + .collect() + }); + + for session_id in worktree_session_ids { + let info = thread_store + .update(cx, |store, cx| store.load_thread(session_id, cx)) + .await? + .and_then(|thread| thread.git_worktree_info); + + if let Some(info) = &info { + remove_worktree_workspace(&this, info, cx).ok(); + cleanup_git_worktree(info, &project, &mut *cx).await; + } + } + + acp_history + .update(cx, |history, cx| history.delete_sessions(cx)) + .await?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); +} + +/// Remove a git worktree from the repository. Logs warnings on failure rather +/// than propagating errors, because cleanup is best-effort — the thread +/// deletion should still proceed even if the worktree removal fails. +fn remove_worktree_workspace( + this: &gpui::WeakEntity, + info: &AgentGitWorktreeInfo, + cx: &mut gpui::AsyncWindowContext, +) -> Result<()> { + let worktree_path = info.worktree_path.clone(); + this.update_in(cx, |_agent_panel, window, cx| { + let Some(Some(multi)) = window.root::() else { + return; + }; + let workspaces = multi.read(cx).workspaces().to_vec(); + for (index, workspace) in workspaces.iter().enumerate().rev() { + let has_matching_worktree = workspace + .read(cx) + .worktrees(cx) + .any(|worktree| worktree.read(cx).abs_path().as_ref() == worktree_path.as_path()); + if has_matching_worktree { + multi.update(cx, |multi, cx| { + multi.remove_workspace(index, window, cx); + }); + break; + } + } + })?; + Ok(()) +} + +async fn cleanup_git_worktree( + info: &AgentGitWorktreeInfo, + project: &gpui::Entity, + cx: &mut gpui::AsyncApp, +) { + let worktree_path = info.worktree_path.clone(); + + let remove_result = project.update(cx, |project, cx| { + let Some(repo) = project.git_store().read(cx).active_repository() else { + log::warn!( + "no active repository to clean up worktree at {}", + worktree_path.display() + ); + return None; + }; + Some(repo.update(cx, |repo, _cx| { + repo.remove_worktree(worktree_path.clone(), true) + })) + }); + + if let Some(receiver) = remove_result { + match receiver.await { + Ok(Ok(())) => { + log::info!( + "cleaned up agent worktree at {}", + info.worktree_path.display() + ); + } + Ok(Err(error)) => { + log::warn!( + "failed to remove agent worktree at {}: {error:#}", + info.worktree_path.display() + ); + } + Err(error) => { + log::warn!( + "failed to remove agent worktree at {}: {error:#}", + info.worktree_path.display() + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_branch_name_generation() { + let name = generate_branch_name(); + + // Verify the prefix + assert!( + name.starts_with("zed/agent/"), + "branch name should start with 'zed/agent/', got: {name}" + ); + + // Verify the short-id length + let short_id = name.strip_prefix("zed/agent/").unwrap(); + assert_eq!( + short_id.len(), + 5, + "short id should be 5 characters, got: {short_id}" + ); + + // Verify all characters are alphanumeric + assert!( + short_id.chars().all(|c| c.is_ascii_alphanumeric()), + "short id should be alphanumeric, got: {short_id}" + ); + + // Verify uniqueness across multiple calls + let names: std::collections::HashSet = + (0..20).map(|_| generate_branch_name()).collect(); + assert!( + names.len() > 1, + "generated names should be unique across calls" + ); + } + + #[test] + fn test_branch_name_is_valid_git_ref() { + for _ in 0..50 { + let name = generate_branch_name(); + // Git branch names cannot contain spaces, ~, ^, :, ?, *, [, \ + // or start/end with a dot, or contain ".." + assert!( + !name.contains(' ') + && !name.contains('~') + && !name.contains('^') + && !name.contains(':') + && !name.contains('?') + && !name.contains('*') + && !name.contains('[') + && !name.contains('\\') + && !name.contains("..") + && !name.starts_with('.') + && !name.ends_with('.'), + "branch name should be a valid git ref: {name}" + ); + } + } + + #[test] + fn test_resolve_worktree_directory_default() { + let result = + resolve_worktree_directory(None, Path::new("/home/user/project"), "my-project") + .unwrap(); + + let expected = paths::data_dir().join("agent-worktrees").join("my-project"); + assert_eq!(result, expected); + } + + #[test] + fn test_resolve_worktree_directory_absolute() { + let result = resolve_worktree_directory( + Some("/custom/worktrees"), + Path::new("/home/user/project"), + "my-project", + ) + .unwrap(); + + assert_eq!(result, PathBuf::from("/custom/worktrees")); + } + + #[test] + fn test_resolve_worktree_directory_relative() { + let result = resolve_worktree_directory( + Some(".worktrees"), + Path::new("/home/user/project"), + "my-project", + ) + .unwrap(); + + assert_eq!(result, PathBuf::from("/home/user/project/.worktrees")); + } + + #[test] + fn test_repo_name_from_path() { + assert_eq!( + repo_name_from_path(Path::new("/home/user/my-project")), + "my-project" + ); + assert_eq!(repo_name_from_path(Path::new("/home/user/zed")), "zed"); + assert_eq!(repo_name_from_path(Path::new("/")), "unknown"); + } +} diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index e44b8ea86c1014..77b51c288b6a52 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -466,6 +466,10 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler(update_context) diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index 63cee5886d5096..b68a4046072b3f 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -1,9 +1,9 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use call::ActiveCall; use git::status::{FileStatus, StatusCode, TrackedStatus}; use git_ui::project_diff::ProjectDiff; -use gpui::{AppContext as _, TestAppContext, VisualTestContext}; +use gpui::{AppContext as _, BackgroundExecutor, TestAppContext, VisualTestContext}; use project::ProjectPath; use serde_json::json; use util::{path, rel_path::rel_path}; @@ -141,3 +141,152 @@ async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) ); }); } + +#[gpui::test] +async fn test_repository_remove_worktree_remote_roundtrip( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + client_a + .fs() + .insert_tree(path!("/project"), json!({ ".git": {} })) + .await; + client_a + .fs() + .insert_branches(Path::new(path!("/project/.git")), &["main"]); + + let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + executor.run_until_parked(); + + // Verify we can call branches() on the remote repo (proven pattern). + let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap()); + let branches = cx_b + .update(|cx| repo_b.update(cx, |repo, _| repo.branches())) + .await + .unwrap() + .unwrap(); + assert!( + branches.iter().any(|b| b.name() == "main"), + "should see main branch via remote" + ); + + // Pre-populate a worktree on the host so we can remove it via remote. + client_a + .fs() + .with_git_state(Path::new(path!("/project/.git")), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: PathBuf::from("/worktrees/test-branch"), + ref_name: "refs/heads/test-branch".into(), + sha: "abc123".into(), + }); + }) + .unwrap(); + + // Remove the worktree via the remote RPC path. + cx_b.update(|cx| { + repo_b.update(cx, |repo, _| { + repo.remove_worktree(PathBuf::from("/worktrees/test-branch"), false) + }) + }) + .await + .unwrap() + .unwrap(); + executor.run_until_parked(); + + // Verify the worktree was removed on the host. + client_a + .fs() + .with_git_state(Path::new(path!("/project/.git")), false, |state| { + assert!( + state.worktrees.is_empty(), + "worktree should be removed on host" + ); + }) + .unwrap(); +} + +#[gpui::test] +async fn test_repository_rename_worktree_remote_roundtrip( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + client_a + .fs() + .insert_tree(path!("/project"), json!({ ".git": {} })) + .await; + client_a + .fs() + .insert_branches(Path::new(path!("/project/.git")), &["main"]); + + let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + executor.run_until_parked(); + + let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap()); + + // Pre-populate a worktree on the host so we can rename it via remote. + client_a + .fs() + .with_git_state(Path::new(path!("/project/.git")), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: PathBuf::from("/worktrees/old-branch"), + ref_name: "refs/heads/old-branch".into(), + sha: "abc123".into(), + }); + }) + .unwrap(); + + // Rename the worktree via the remote RPC path. + cx_b.update(|cx| { + repo_b.update(cx, |repo, _| { + repo.rename_worktree( + PathBuf::from("/worktrees/old-branch"), + PathBuf::from("/worktrees/new-branch"), + ) + }) + }) + .await + .unwrap() + .unwrap(); + executor.run_until_parked(); + + // Verify the worktree was renamed on the host. + client_a + .fs() + .with_git_state(Path::new(path!("/project/.git")), false, |state| { + assert_eq!(state.worktrees.len(), 1, "should still have one worktree"); + assert_eq!( + state.worktrees[0].path, + PathBuf::from("/worktrees/new-branch"), + "worktree path should be renamed on host" + ); + }) + .unwrap(); +} diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 654a541a699b62..d21e6910e7ad0e 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -221,7 +221,13 @@ impl Settings for EditorSettings { scrollbar: Scrollbar { show: scrollbar.show.map(Into::into).unwrap(), git_diff: scrollbar.git_diff.unwrap() - && content.git.unwrap().enabled.unwrap().is_git_diff_enabled(), + && content + .git + .as_ref() + .unwrap() + .enabled + .unwrap() + .is_git_diff_enabled(), selected_text: scrollbar.selected_text.unwrap(), selected_symbol: scrollbar.selected_symbol.unwrap(), search_results: scrollbar.search_results.unwrap(), diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 2057cd1d859587..391a5b42e35a3f 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -49,8 +49,10 @@ pub struct FakeGitRepositoryState { /// List of remotes, keys are names and values are URLs pub remotes: HashMap, pub simulated_index_write_error_message: Option, + pub simulated_create_worktree_error: Option, pub refs: HashMap, pub graph_commits: Vec>, + pub worktrees: Vec, } impl FakeGitRepositoryState { @@ -64,11 +66,13 @@ impl FakeGitRepositoryState { current_branch_name: Default::default(), branches: Default::default(), simulated_index_write_error_message: Default::default(), + simulated_create_worktree_error: Default::default(), refs: HashMap::from_iter([("HEAD".into(), "abc".into())]), merge_base_contents: Default::default(), oids: Default::default(), remotes: HashMap::default(), graph_commits: Vec::new(), + worktrees: Vec::new(), } } } @@ -402,16 +406,69 @@ impl GitRepository for FakeGitRepository { } fn worktrees(&self) -> BoxFuture<'_, Result>> { - unimplemented!() + self.with_state_async(false, |state| Ok(state.worktrees.clone())) } fn create_worktree( &self, - _: String, - _: PathBuf, - _: Option, + name: String, + directory: PathBuf, + from_commit: Option, ) -> BoxFuture<'_, Result<()>> { - unimplemented!() + let fs = self.fs.clone(); + let executor = self.executor.clone(); + let dot_git_path = self.dot_git_path.clone(); + async move { + let path = directory.join(&name); + executor.simulate_random_delay().await; + fs.with_git_state(&dot_git_path, true, { + let path = path.clone(); + move |state| { + if let Some(message) = &state.simulated_create_worktree_error { + anyhow::bail!("{message}"); + } + let ref_name = format!("refs/heads/{name}"); + let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string()); + state.worktrees.push(Worktree { + path, + ref_name: ref_name.into(), + sha: sha.into(), + }); + state.branches.insert(name); + Ok::<(), anyhow::Error>(()) + } + })??; + fs.create_dir(&path).await?; + Ok(()) + } + .boxed() + } + + fn remove_worktree(&self, path: PathBuf, _force: bool) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + let initial_len = state.worktrees.len(); + state.worktrees.retain(|worktree| worktree.path != path); + if state.worktrees.len() == initial_len { + bail!("no worktree found at path: {}", path.display()); + } + Ok(()) + }) + } + + fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + let worktree = state + .worktrees + .iter_mut() + .find(|worktree| worktree.path == old_path); + match worktree { + Some(worktree) => { + worktree.path = new_path; + Ok(()) + } + None => bail!("no worktree found at path: {}", old_path.display()), + } + }) } fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { @@ -765,3 +822,109 @@ impl GitRepository for FakeGitRepository { anyhow::bail!("commit_data_reader not supported for FakeGitRepository") } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FakeFs, Fs}; + use gpui::TestAppContext; + use serde_json::json; + use std::path::Path; + + #[gpui::test] + async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({".git": {}, "file.txt": "content"})) + .await; + let repo = fs + .open_repo(Path::new("/project/.git"), None) + .expect("should open fake repo"); + + // Initially no worktrees + let worktrees = repo.worktrees().await.unwrap(); + assert!(worktrees.is_empty()); + + // Create a worktree + repo.create_worktree( + "feature-branch".to_string(), + PathBuf::from("/worktrees"), + Some("abc123".to_string()), + ) + .await + .unwrap(); + + // List worktrees — should have one + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 1); + assert_eq!( + worktrees[0].path, + PathBuf::from("/worktrees/feature-branch") + ); + assert_eq!(worktrees[0].ref_name.as_ref(), "refs/heads/feature-branch"); + assert_eq!(worktrees[0].sha.as_ref(), "abc123"); + + // Create a second worktree (without explicit commit) + repo.create_worktree( + "bugfix-branch".to_string(), + PathBuf::from("/worktrees"), + None, + ) + .await + .unwrap(); + + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 2); + + // Rename the first worktree + repo.rename_worktree( + PathBuf::from("/worktrees/feature-branch"), + PathBuf::from("/worktrees/renamed-branch"), + ) + .await + .unwrap(); + + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 2); + assert!( + worktrees + .iter() + .any(|w| w.path == PathBuf::from("/worktrees/renamed-branch")), + "renamed worktree should exist at new path" + ); + assert!( + worktrees + .iter() + .all(|w| w.path != PathBuf::from("/worktrees/feature-branch")), + "old path should no longer exist" + ); + + // Rename a nonexistent worktree should fail + let result = repo + .rename_worktree(PathBuf::from("/nonexistent"), PathBuf::from("/somewhere")) + .await; + assert!(result.is_err()); + + // Remove a worktree + repo.remove_worktree(PathBuf::from("/worktrees/renamed-branch"), false) + .await + .unwrap(); + + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 1); + assert_eq!(worktrees[0].path, PathBuf::from("/worktrees/bugfix-branch")); + + // Remove a nonexistent worktree should fail + let result = repo + .remove_worktree(PathBuf::from("/nonexistent"), false) + .await; + assert!(result.is_err()); + + // Remove the last worktree + repo.remove_worktree(PathBuf::from("/worktrees/bugfix-branch"), false) + .await + .unwrap(); + + let worktrees = repo.worktrees().await.unwrap(); + assert!(worktrees.is_empty()); + } +} diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 75ce789aafd38b..d7e631dabe1e1a 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -2069,6 +2069,13 @@ impl FakeFs { .unwrap(); } + pub fn set_create_worktree_error(&self, dot_git: &Path, message: Option) { + self.with_git_state(dot_git, true, |state| { + state.simulated_create_worktree_error = message; + }) + .unwrap(); + } + pub fn paths(&self, include_dot_git: bool) -> Vec { let mut result = Vec::new(); let mut queue = collections::VecDeque::new(); diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 304a0f2c95dc8e..07c2b909e028a6 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -629,6 +629,10 @@ pub trait GitRepository: Send + Sync { from_commit: Option, ) -> BoxFuture<'_, Result<()>>; + fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>>; + + fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>>; + fn reset( &self, commit: String, @@ -1599,6 +1603,52 @@ impl GitRepository for RealGitRepository { .boxed() } + fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>> { + let git_binary_path = self.any_git_binary_path.clone(); + let working_directory = self.working_directory(); + let executor = self.executor.clone(); + + self.executor + .spawn(async move { + let mut args: Vec = vec![ + "--no-optional-locks".into(), + "worktree".into(), + "remove".into(), + path.as_os_str().into(), + ]; + if force { + args.push("--force".into()); + } + GitBinary::new(git_binary_path, working_directory?, executor) + .run(args) + .await?; + anyhow::Ok(()) + }) + .boxed() + } + + fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> { + let git_binary_path = self.any_git_binary_path.clone(); + let working_directory = self.working_directory(); + let executor = self.executor.clone(); + + self.executor + .spawn(async move { + let args: Vec = vec![ + "--no-optional-locks".into(), + "worktree".into(), + "move".into(), + old_path.as_os_str().into(), + new_path.as_os_str().into(), + ]; + GitBinary::new(git_binary_path, working_directory?, executor) + .run(args) + .await?; + anyhow::Ok(()) + }) + .boxed() + } + fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { let repo = self.repository.clone(); let working_directory = self.working_directory(); @@ -3576,6 +3626,307 @@ mod tests { assert_eq!(upstream.branch_name(), Some("feature/git-pull-request")); } + #[test] + fn test_parse_worktrees_from_str() { + // Empty input + let result = parse_worktrees_from_str(""); + assert!(result.is_empty()); + + // Single worktree (main) + let input = "worktree /home/user/project\nHEAD abc123def\nbranch refs/heads/main\n\n"; + let result = parse_worktrees_from_str(input); + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, PathBuf::from("/home/user/project")); + assert_eq!(result[0].sha.as_ref(), "abc123def"); + assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main"); + + // Multiple worktrees + let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\ + worktree /home/user/project-wt\nHEAD def456\nbranch refs/heads/feature\n\n"; + let result = parse_worktrees_from_str(input); + assert_eq!(result.len(), 2); + assert_eq!(result[0].path, PathBuf::from("/home/user/project")); + assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main"); + assert_eq!(result[1].path, PathBuf::from("/home/user/project-wt")); + assert_eq!(result[1].ref_name.as_ref(), "refs/heads/feature"); + + // Detached HEAD entry (should be skipped since ref_name won't parse) + let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\ + worktree /home/user/detached\nHEAD def456\ndetached\n\n"; + let result = parse_worktrees_from_str(input); + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, PathBuf::from("/home/user/project")); + + // Bare repo entry (should be skipped) + let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\ + worktree /home/user/project\nHEAD def456\nbranch refs/heads/main\n\n"; + let result = parse_worktrees_from_str(input); + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, PathBuf::from("/home/user/project")); + } + + #[gpui::test] + async fn test_create_and_list_worktrees(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + git2::Repository::init(repo_dir.path()).unwrap(); + + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + // Create an initial commit (required for worktrees) + smol::fs::write(repo_dir.path().join("file.txt"), "content") + .await + .unwrap(); + repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default())) + .await + .unwrap(); + repo.commit( + "Initial commit".into(), + None, + CommitOptions::default(), + AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), + Arc::new(checkpoint_author_envs()), + ) + .await + .unwrap(); + + // List worktrees — should have just the main one + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 1); + assert_eq!(worktrees[0].path, repo_dir.path().canonicalize().unwrap()); + + // Create a new worktree + let worktree_dir = tempfile::tempdir().unwrap(); + repo.create_worktree( + "test-branch".to_string(), + worktree_dir.path().to_path_buf(), + Some("HEAD".to_string()), + ) + .await + .unwrap(); + + // List worktrees — should have two + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 2); + + let new_worktree = worktrees + .iter() + .find(|w| w.branch() == "test-branch") + .expect("should find worktree with test-branch"); + assert_eq!( + new_worktree.path, + worktree_dir + .path() + .join("test-branch") + .canonicalize() + .unwrap() + ); + } + + #[gpui::test] + async fn test_remove_worktree(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + git2::Repository::init(repo_dir.path()).unwrap(); + + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + // Create an initial commit + smol::fs::write(repo_dir.path().join("file.txt"), "content") + .await + .unwrap(); + repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default())) + .await + .unwrap(); + repo.commit( + "Initial commit".into(), + None, + CommitOptions::default(), + AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), + Arc::new(checkpoint_author_envs()), + ) + .await + .unwrap(); + + // Create a worktree + let worktree_dir = tempfile::tempdir().unwrap(); + repo.create_worktree( + "to-remove".to_string(), + worktree_dir.path().to_path_buf(), + Some("HEAD".to_string()), + ) + .await + .unwrap(); + + let worktree_path = worktree_dir.path().join("to-remove"); + assert!(worktree_path.exists()); + + // Remove the worktree + repo.remove_worktree(worktree_path.clone(), false) + .await + .unwrap(); + + // Verify it's gone from the list + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 1); + assert!( + worktrees.iter().all(|w| w.branch() != "to-remove"), + "removed worktree should not appear in list" + ); + + // Verify the directory is removed + assert!(!worktree_path.exists()); + } + + #[gpui::test] + async fn test_remove_worktree_force(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + git2::Repository::init(repo_dir.path()).unwrap(); + + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + // Create an initial commit + smol::fs::write(repo_dir.path().join("file.txt"), "content") + .await + .unwrap(); + repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default())) + .await + .unwrap(); + repo.commit( + "Initial commit".into(), + None, + CommitOptions::default(), + AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), + Arc::new(checkpoint_author_envs()), + ) + .await + .unwrap(); + + // Create a worktree + let worktree_dir = tempfile::tempdir().unwrap(); + repo.create_worktree( + "dirty-wt".to_string(), + worktree_dir.path().to_path_buf(), + Some("HEAD".to_string()), + ) + .await + .unwrap(); + + let worktree_path = worktree_dir.path().join("dirty-wt"); + + // Add uncommitted changes in the worktree + smol::fs::write(worktree_path.join("dirty-file.txt"), "uncommitted") + .await + .unwrap(); + + // Non-force removal should fail with dirty worktree + let result = repo.remove_worktree(worktree_path.clone(), false).await; + assert!( + result.is_err(), + "non-force removal of dirty worktree should fail" + ); + + // Force removal should succeed + repo.remove_worktree(worktree_path.clone(), true) + .await + .unwrap(); + + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 1); + assert!(!worktree_path.exists()); + } + + #[gpui::test] + async fn test_rename_worktree(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + git2::Repository::init(repo_dir.path()).unwrap(); + + let repo = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + // Create an initial commit + smol::fs::write(repo_dir.path().join("file.txt"), "content") + .await + .unwrap(); + repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default())) + .await + .unwrap(); + repo.commit( + "Initial commit".into(), + None, + CommitOptions::default(), + AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), + Arc::new(checkpoint_author_envs()), + ) + .await + .unwrap(); + + // Create a worktree + let worktree_dir = tempfile::tempdir().unwrap(); + repo.create_worktree( + "old-name".to_string(), + worktree_dir.path().to_path_buf(), + Some("HEAD".to_string()), + ) + .await + .unwrap(); + + let old_path = worktree_dir.path().join("old-name"); + assert!(old_path.exists()); + + // Move the worktree to a new path + let new_path = worktree_dir.path().join("new-name"); + repo.rename_worktree(old_path.clone(), new_path.clone()) + .await + .unwrap(); + + // Verify the old path is gone and new path exists + assert!(!old_path.exists()); + assert!(new_path.exists()); + + // Verify it shows up in worktree list at the new path + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 2); + let moved_worktree = worktrees + .iter() + .find(|w| w.branch() == "old-name") + .expect("should find worktree by branch name"); + assert_eq!(moved_worktree.path, new_path.canonicalize().unwrap()); + } + impl RealGitRepository { /// Force a Git garbage collection on the repository. fn gc(&self) -> BoxFuture<'_, Result<()>> { diff --git a/crates/outline_panel/src/outline_panel_settings.rs b/crates/outline_panel/src/outline_panel_settings.rs index bf73aebecc194b..b744ca6399dd16 100644 --- a/crates/outline_panel/src/outline_panel_settings.rs +++ b/crates/outline_panel/src/outline_panel_settings.rs @@ -53,6 +53,7 @@ impl Settings for OutlinePanelSettings { git_status: panel.git_status.unwrap() && content .git + .as_ref() .unwrap() .enabled .unwrap() diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 2872d446e9cbaa..a7c2cb5244beb2 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -541,6 +541,8 @@ impl GitStore { client.add_entity_request_handler(Self::handle_git_clone); client.add_entity_request_handler(Self::handle_get_worktrees); client.add_entity_request_handler(Self::handle_create_worktree); + client.add_entity_request_handler(Self::handle_remove_worktree); + client.add_entity_request_handler(Self::handle_rename_worktree); } pub fn is_local(&self) -> bool { @@ -2298,6 +2300,44 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_remove_worktree( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let path = PathBuf::from(envelope.payload.path); + let force = envelope.payload.force; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.remove_worktree(path, force) + }) + .await??; + + Ok(proto::Ack {}) + } + + async fn handle_rename_worktree( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let old_path = PathBuf::from(envelope.payload.old_path); + let new_path = PathBuf::from(envelope.payload.new_path); + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.rename_worktree(old_path, new_path) + }) + .await??; + + Ok(proto::Ack {}) + } + async fn handle_get_branches( this: Entity, envelope: TypedEnvelope, @@ -5561,6 +5601,62 @@ impl Repository { ) } + pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + Some("git worktree remove".into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.remove_worktree(path, force).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitRemoveWorktree { + project_id: project_id.0, + repository_id: id.to_proto(), + path: path.to_string_lossy().to_string(), + force, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + + pub fn rename_worktree( + &mut self, + old_path: PathBuf, + new_path: PathBuf, + ) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + Some("git worktree move".into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.rename_worktree(old_path, new_path).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitRenameWorktree { + project_id: project_id.0, + repository_id: id.to_proto(), + old_path: old_path.to_string_lossy().to_string(), + new_path: new_path.to_string_lossy().to_string(), + }) + .await?; + + Ok(()) + } + } + }, + ) + } + pub fn default_branch( &mut self, include_remote_name: bool, diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index c295938ef56f69..b25a6d9a1d995e 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -421,7 +421,7 @@ impl GoToDiagnosticSeverityFilter { } } -#[derive(Copy, Clone, Debug)] +#[derive(Clone, Debug)] pub struct GitSettings { /// Whether or not git integration is enabled. /// @@ -454,6 +454,9 @@ pub struct GitSettings { /// /// Default: file_name_first pub path_style: GitPathStyle, + /// Directory where agent worktrees are created. + /// If not set, defaults to the Zed data directory. + pub agent_worktree_directory: Option, } #[derive(Clone, Copy, Debug)] @@ -643,6 +646,7 @@ impl Settings for ProjectSettings { }, hunk_style: git.hunk_style.unwrap(), path_style: git.path_style.unwrap().into(), + agent_worktree_directory: git.agent_worktree_directory.clone(), }; Self { context_servers: project diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index ffa126a01addd6..6b6b7a377276a9 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -96,6 +96,7 @@ impl Settings for ProjectPanelSettings { git_status: project_panel.git_status.unwrap() && content .git + .as_ref() .unwrap() .enabled .unwrap() diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index eda4fa9b5dc28d..ccac7002f65486 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -579,6 +579,20 @@ message GitCreateWorktree { optional string commit = 5; } +message GitRemoveWorktree { + uint64 project_id = 1; + uint64 repository_id = 2; + string path = 3; + bool force = 4; +} + +message GitRenameWorktree { + uint64 project_id = 1; + uint64 repository_id = 2; + string old_path = 3; + string new_path = 4; +} + message RunGitHook { enum GitHook { PRE_COMMIT = 0; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 1246d0dda41d7f..644faac3e7d4fd 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -457,7 +457,7 @@ message Envelope { FindSearchCandidatesCancelled find_search_candidates_cancelled = 410; GetContextServerCommand get_context_server_command = 411; ContextServerCommand context_server_command = 412; - + AllocateWorktreeId allocate_worktree_id = 413; AllocateWorktreeIdResponse allocate_worktree_id_response = 414; @@ -469,7 +469,10 @@ message Envelope { SemanticTokensResponse semantic_tokens_response = 419; RefreshSemanticTokens refresh_semantic_tokens = 420; GetFoldingRanges get_folding_ranges = 421; - GetFoldingRangesResponse get_folding_ranges_response = 422; // current max + GetFoldingRangesResponse get_folding_ranges_response = 422; + + GitRemoveWorktree git_remove_worktree = 423; + GitRenameWorktree git_rename_worktree = 424; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 4bd716d92d899d..0c6fef7a5069c1 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -354,6 +354,8 @@ messages!( (GitGetWorktrees, Background), (GitWorktreesResponse, Background), (GitCreateWorktree, Background), + (GitRemoveWorktree, Background), + (GitRenameWorktree, Background), (ShareAgentThread, Foreground), (GetSharedAgentThread, Foreground), (GetSharedAgentThreadResponse, Foreground), @@ -552,6 +554,8 @@ request_messages!( (RemoteStarted, Ack), (GitGetWorktrees, GitWorktreesResponse), (GitCreateWorktree, Ack), + (GitRemoveWorktree, Ack), + (GitRenameWorktree, Ack), (TrustWorktrees, Ack), (RestrictWorktrees, Ack), (FindSearchCandidatesChunk, Ack), @@ -737,6 +741,8 @@ entity_messages!( NewExternalAgentVersionAvailable, GitGetWorktrees, GitCreateWorktree, + GitRemoveWorktree, + GitRenameWorktree, TrustWorktrees, RestrictWorktrees, FindSearchCandidatesChunk, diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index 59576651de0463..45bedf04d3a976 100644 --- a/crates/settings_content/src/project.rs +++ b/crates/settings_content/src/project.rs @@ -439,7 +439,7 @@ impl std::fmt::Debug for ContextServerCommand { } #[with_fallible_options] -#[derive(Copy, Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)] +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct GitSettings { /// Whether or not to enable git integration. /// @@ -473,6 +473,13 @@ pub struct GitSettings { /// /// Default: file_name_first pub path_style: Option, + /// Directory where agent worktrees are created. + /// If not set, defaults to the Zed data directory. + /// + /// Can be an absolute path or relative to the project root. + /// + /// Default: null (uses system default) + pub agent_worktree_directory: Option, } #[with_fallible_options] diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 058f42b2a267b7..4230c529094604 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -79,6 +79,7 @@ impl Settings for ItemSettings { git_status: tabs.git_status.unwrap() && content .git + .as_ref() .unwrap() .enabled .unwrap() diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 136977f95f60a9..874cb569a2d430 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -450,6 +450,8 @@ pub mod agent { AddSelectionToThread, /// Resets the agent panel zoom levels (agent UI and buffer font sizes). ResetAgentZoom, + /// Toggles the utility/agent pane open/closed state. + ToggleAgentPane, /// Pastes clipboard content without any formatting. PasteRaw, ]