From 5486ce8a5db64b5d1c560a762c1608743f7676a8 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 26 Jul 2026 23:06:07 -0700 Subject: [PATCH 1/3] feat(worktrees): add managed worktree sessions --- src/apps/cli/src/agent/runtime_client.rs | 8 + .../cli/src/peer_host/commands/session.rs | 2 + src/apps/desktop/src/api/agentic_api.rs | 290 ++- src/apps/desktop/src/api/mod.rs | 1 + .../src/api/remote_workspace_policy.rs | 12 + src/apps/desktop/src/api/worktree_api.rs | 76 + src/apps/desktop/src/lib.rs | 6 + .../src/agentic/coordination/coordinator.rs | 29 +- .../core/src/agentic/persistence/manager.rs | 2 + .../src/agentic/session/session_manager.rs | 8 +- .../tools/implementations/cron_tool.rs | 13 +- .../src/agentic/tools/implementations/mod.rs | 2 + .../implementations/session_control_tool.rs | 50 +- .../implementations/session_message_tool.rs | 79 +- .../tools/implementations/worktree_tool.rs | 617 +++++++ .../tools/product_runtime/materialization.rs | 1 + .../core/src/agentic/tools/registry.rs | 3 + .../src/agentic/tools/tool_context_runtime.rs | 8 + .../assembly/core/src/agentic/workspace.rs | 70 +- .../infrastructure/app_paths/path_manager.rs | 5 + .../assembly/core/src/service/config/types.rs | 5 + .../core/src/service/cron/schedule.rs | 2 + .../assembly/core/src/service/cron/service.rs | 19 + .../assembly/core/src/service/cron/store.rs | 2 + .../assembly/core/src/service/cron/types.rs | 5 + src/crates/assembly/core/src/service/mod.rs | 4 + .../src/service/workspace_runtime/service.rs | 2 +- .../assembly/core/src/service/worktree/mod.rs | 1593 +++++++++++++++++ src/crates/contracts/core-types/src/lib.rs | 6 + .../contracts/core-types/src/worktree.rs | 238 +++ src/crates/contracts/events/src/agentic.rs | 11 +- .../events/src/frontend_projection.rs | 38 + src/crates/contracts/runtime-ports/src/lib.rs | 18 + .../execution/agent-runtime/src/runtime.rs | 8 + .../execution/agent-runtime/src/session.rs | 15 +- .../execution/tool-provider-groups/src/lib.rs | 2 + .../interfaces/acp/src/runtime/session.rs | 2 + src/crates/interfaces/sdk-host/src/host.rs | 4 + .../sdk-host/tests/host_lifecycle.rs | 2 + .../services/services-core/src/json_store.rs | 8 +- .../services-core/src/session/metadata.rs | 58 +- .../services-core/src/session/types.rs | 23 +- .../src/git/managed_worktree.rs | 681 +++++++ .../services-integrations/src/git/mod.rs | 1 + .../services-integrations/src/git/types.rs | 19 + .../services-integrations/src/git/worktree.rs | 23 +- .../src/remote_connect.rs | 10 + .../src/remote_connect/bot/mod.rs | 7 +- .../src/remote_connect/bot/weixin.rs | 6 +- .../src/remote_connect/device.rs | 8 +- .../src/remote_connect/session_store.rs | 6 +- .../src/remote_connect/sync_state.rs | 8 +- .../src/app/components/NavPanel/MainNav.tsx | 34 +- .../sections/sessions/SessionsSection.tsx | 47 +- .../sections/workspaces/ProjectWorktrees.scss | 123 ++ .../sections/workspaces/ProjectWorktrees.tsx | 319 ++++ .../sections/workspaces/WorkspaceItem.tsx | 120 +- .../workspaces/WorkspaceListSection.tsx | 28 +- .../workspaces/WorktreeLauncherModal.scss | 83 + .../workspaces/WorktreeLauncherModal.test.tsx | 208 +++ .../workspaces/WorktreeLauncherModal.tsx | 320 ++++ .../workspaces/WorktreeManagerModal.scss | 120 ++ .../workspaces/WorktreeManagerModal.test.tsx | 217 +++ .../workspaces/WorktreeManagerModal.tsx | 385 ++++ .../src/app/scenes/settings/SettingsScene.tsx | 2 + .../src/app/scenes/settings/settingsConfig.ts | 14 + .../settings/settingsContentRegistry.ts | 3 + .../settings/settingsTabSearchContent.ts | 10 + .../src/flow_chat/components/ChatInput.tsx | 73 +- .../components/ChatInputWorkspaceStrip.scss | 66 + .../ChatInputWorkspaceStrip.test.tsx | 74 + .../components/ChatInputWorkspaceStrip.tsx | 226 ++- .../components/btw/BtwSessionPanel.tsx | 3 +- .../deep-review/launch/DeepReviewService.ts | 5 +- .../flow_chat/services/BtwThreadService.ts | 35 +- .../ReviewActionBarPersistenceService.ts | 7 +- .../flow-chat-manager/EventHandlerModule.ts | 26 +- .../flow-chat-manager/PersistenceModule.ts | 5 +- .../flow-chat-manager/SessionModule.ts | 73 +- .../src/flow_chat/services/goalService.ts | 3 +- .../flow_chat/services/usageReportService.ts | 10 +- .../src/flow_chat/store/FlowChatStore.ts | 52 +- src/web-ui/src/flow_chat/types/flow-chat.ts | 9 + .../src/flow_chat/utils/sessionMetadata.ts | 8 + .../flow_chat/utils/sessionOrdering.test.ts | 16 + .../src/flow_chat/utils/sessionOrdering.ts | 12 +- .../flow_chat/utils/sessionWorkspace.test.ts | 44 + .../src/flow_chat/utils/sessionWorkspace.ts | 35 + src/web-ui/src/infrastructure/api/index.ts | 5 +- .../api/service-api/AgentAPI.test.ts | 25 + .../api/service-api/AgentAPI.ts | 15 + .../api/service-api/WorktreeAPI.test.ts | 62 + .../api/service-api/WorktreeAPI.ts | 212 +++ .../config/components/WorktreesConfig.scss | 12 + .../config/components/WorktreesConfig.tsx | 186 ++ .../i18n/presets/namespaceRegistry.ts | 1 + src/web-ui/src/locales/en-US/settings.json | 2 + src/web-ui/src/locales/en-US/worktrees.json | 121 ++ src/web-ui/src/locales/zh-CN/settings.json | 2 + src/web-ui/src/locales/zh-CN/worktrees.json | 121 ++ src/web-ui/src/locales/zh-TW/settings.json | 2 + src/web-ui/src/locales/zh-TW/worktrees.json | 121 ++ .../src/shared/services/worktreeUIEvents.ts | 19 + .../src/shared/types/session-history.ts | 3 + tests/e2e/config/embedded-driver.ts | 12 + tests/e2e/package.json | 2 + .../e2e/scripts/run-worktree-restart-e2e.mjs | 97 + tests/e2e/specs/l1-worktree-restart.spec.ts | 340 ++++ tests/e2e/specs/l1-worktree.spec.ts | 397 ++++ 109 files changed, 8502 insertions(+), 186 deletions(-) create mode 100644 src/apps/desktop/src/api/worktree_api.rs create mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs create mode 100644 src/crates/assembly/core/src/service/worktree/mod.rs create mode 100644 src/crates/contracts/core-types/src/worktree.rs create mode 100644 src/crates/services/services-integrations/src/git/managed_worktree.rs create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/ProjectWorktrees.scss create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/ProjectWorktrees.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.scss create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.test.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.scss create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.test.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.tsx create mode 100644 src/web-ui/src/flow_chat/utils/sessionWorkspace.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/sessionWorkspace.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.test.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.ts create mode 100644 src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss create mode 100644 src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx create mode 100644 src/web-ui/src/locales/en-US/worktrees.json create mode 100644 src/web-ui/src/locales/zh-CN/worktrees.json create mode 100644 src/web-ui/src/locales/zh-TW/worktrees.json create mode 100644 src/web-ui/src/shared/services/worktreeUIEvents.ts create mode 100644 tests/e2e/scripts/run-worktree-restart-e2e.mjs create mode 100644 tests/e2e/specs/l1-worktree-restart.spec.ts create mode 100644 tests/e2e/specs/l1-worktree.spec.ts diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index fc558a29e7..081984a2b6 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -377,6 +377,8 @@ impl CliAgentRuntimeClient { session_name, agent_type: effective_agent_type, workspace_path: Some(self.workspace_path_string()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -439,6 +441,8 @@ impl CliAgentRuntimeClient { session_name: Self::build_default_session_name(), agent_type: agent_type.to_string(), workspace_path: Some(self.workspace_path_string()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -471,6 +475,8 @@ impl CliAgentRuntimeClient { session_name: Self::build_default_session_name(), agent_type: agent_type.to_string(), workspace_path: Some(self.workspace_path_string()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -579,6 +585,8 @@ impl CliAgentRuntimeClient { session_name: Self::build_default_session_name(), agent_type: agent_type.to_string(), workspace_path: Some(self.workspace_path_string()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index c18103c0b4..5ba6460f92 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -279,6 +279,8 @@ pub(crate) async fn create_session(state: &PeerHostState, args: &Value) -> Resul session_name, agent_type, workspace_path: Some(workspace_path), + project_workspace_path: None, + execution_target: None, workspace_id, remote_connection_id, remote_ssh_host, diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index c2bcfdc37b..56755bedd0 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -54,6 +54,12 @@ use bitfun_core::service::session::{ SessionRelationshipKind, }; use bitfun_core::service::workspace::WorkspaceKind; +use bitfun_core::service::workspace::{WorkspaceActivityMode, WorkspaceCreateOptions}; +use bitfun_core::service::worktree::{WorktreeCreateRequest, WorktreeListRequest, WorktreeService}; +use bitfun_core_types::{ + SessionExecutionTarget, SessionExecutionTargetKind, SessionExecutionTargetRequest, + WorktreeError, WorktreeErrorCode, +}; use bitfun_product_domains::tool_permissions::PermissionRule; const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024; @@ -61,6 +67,22 @@ const SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT: usize = 16 * 1024; const SESSION_VIEW_TRUNCATED_MARKER: &str = "\n... Output truncated for session preview"; const SESSION_VIEW_OMITTED_MARKER: &str = "Output omitted from session preview"; +fn encode_worktree_error(error: WorktreeError) -> String { + serde_json::to_string(&error).unwrap_or_else(|_| error.to_string()) +} + +fn worktree_error( + code: WorktreeErrorCode, + message: impl Into, + recovery_path: Option, +) -> String { + encode_worktree_error(WorktreeError { + code, + message: message.into(), + recovery_path, + }) +} + fn desktop_session_scope( workspace_path: String, remote_connection_id: Option, @@ -80,6 +102,16 @@ pub struct CreateSessionRequest { pub session_name: String, pub agent_type: String, pub workspace_path: String, + /// Main project scope for persistence. Legacy clients omit this and use + /// `workspacePath`. + #[serde(default)] + pub project_workspace_path: Option, + /// Optional opt-in execution isolation. + #[serde(default)] + pub execution_target: Option, + /// Idempotency key used when `executionTarget` creates a managed worktree. + #[serde(default)] + pub request_id: Option, #[serde(default)] pub workspace_id: Option, #[serde(default)] @@ -119,6 +151,14 @@ pub struct CreateSessionResponse { pub session_id: String, pub session_name: String, pub agent_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_workspace_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub execution_target: Option, } fn existing_session_create_response( @@ -168,6 +208,10 @@ fn existing_session_create_response( session_id: metadata.session_id.clone(), session_name: metadata.session_name.clone(), agent_type: metadata.agent_type.clone(), + workspace_path: metadata.workspace_path.clone(), + workspace_id: None, + project_workspace_path: metadata.project_workspace_path.clone(), + execution_target: metadata.execution_target.clone(), }) } @@ -1204,7 +1248,6 @@ pub async fn create_session( s.map(|x| x.trim().to_string()).filter(|x| !x.is_empty()) } sanitize_create_session_review_metadata(&mut request); - let wp = request.workspace_path.clone(); let remote_conn = norm_conn(request.remote_connection_id.clone()).or_else(|| { request .config @@ -1218,6 +1261,187 @@ pub async fn create_session( .and_then(|c| norm_conn(c.remote_ssh_host.clone())) }); + let source_workspace_path = request.workspace_path.clone(); + let is_idempotent_managed_create = matches!( + request.execution_target.as_ref(), + Some(SessionExecutionTargetRequest::NewManagedWorktree { .. }) + ); + if is_idempotent_managed_create { + let request_id = request + .request_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + if request.session_id.is_none() { + request.session_id = Some( + WorktreeService::session_id_for_request(&request_id) + .map_err(|error| error.to_string())?, + ); + } + request.request_id = Some(request_id); + } + let mut project_workspace_path = request + .project_workspace_path + .clone() + .unwrap_or_else(|| source_workspace_path.clone()); + let requested_execution_target = request.execution_target.clone().unwrap_or_default(); + let mut created_worktree_id = None; + let resolved_execution_target = match requested_execution_target { + SessionExecutionTargetRequest::Local => { + SessionExecutionTarget::local(source_workspace_path.clone()) + } + SessionExecutionTargetRequest::NewManagedWorktree { + base_ref, + copy_local_changes, + } => { + if remote_conn.is_some() { + return Err(worktree_error( + WorktreeErrorCode::RemoteUnsupported, + "Managed worktrees are not supported for remote SSH workspaces yet", + None, + )); + } + let result = WorktreeService::create(WorktreeCreateRequest { + request_id: request + .request_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + project_workspace_path: project_workspace_path.clone(), + source_workspace_path: Some(source_workspace_path.clone()), + base_ref, + copy_local_changes, + }) + .await + .map_err(|error| serde_json::to_string(&error).unwrap_or_else(|_| error.to_string()))?; + project_workspace_path = result.worktree.project_workspace_path.clone(); + request.workspace_path = result.execution_target.root_path.clone(); + if result.created { + created_worktree_id = result.execution_target.worktree_id.clone(); + } + result.execution_target + } + SessionExecutionTargetRequest::ExistingWorktree { worktree_id } => { + if remote_conn.is_some() { + return Err(worktree_error( + WorktreeErrorCode::RemoteUnsupported, + "Managed worktrees are not supported for remote SSH workspaces yet", + None, + )); + } + let worktree = WorktreeService::list(WorktreeListRequest { + project_workspace_path: project_workspace_path.clone(), + }) + .await + .map_err(|error| serde_json::to_string(&error).unwrap_or_else(|_| error.to_string()))? + .into_iter() + .find(|worktree| worktree.worktree_id == worktree_id) + .ok_or_else(|| { + worktree_error( + WorktreeErrorCode::WorktreeNotFound, + "The selected worktree no longer exists", + None, + ) + })?; + if worktree.missing { + return Err(worktree_error( + WorktreeErrorCode::WorktreeNotFound, + "The selected worktree directory is missing; recreate it first", + Some(worktree.path), + )); + } + project_workspace_path = worktree.project_workspace_path.clone(); + request.workspace_path = worktree.path.clone(); + SessionExecutionTarget { + kind: SessionExecutionTargetKind::ExistingWorktree, + worktree_id: Some(worktree.worktree_id), + root_path: worktree.path, + base_ref: worktree.branch.clone(), + base_commit: Some(worktree.head), + branch: worktree.branch, + lifecycle: Some(worktree.lifecycle), + } + } + }; + request.project_workspace_path = Some(project_workspace_path.clone()); + let wp = project_workspace_path.clone(); + + let tracked_worktree_workspace_id = if resolved_execution_target.kind + != SessionExecutionTargetKind::Local + { + match app_state + .workspace_service + .track_workspace_activity( + PathBuf::from(&request.workspace_path), + WorkspaceCreateOptions::default(), + WorkspaceActivityMode::RefreshMetadata, + ) + .await + { + Ok(workspace) => { + request.workspace_id = Some(workspace.id.clone()); + Some(workspace.id) + } + Err(track_error) => { + if let Some(worktree_id) = created_worktree_id.as_deref() { + if let Err(rollback_error) = + WorktreeService::rollback_created(&project_workspace_path, worktree_id) + .await + { + return Err(worktree_error( + WorktreeErrorCode::RollbackIncomplete, + format!( + "Failed to register worktree workspace: {track_error}; rollback failed: {rollback_error}" + ), + Some(resolved_execution_target.root_path.clone()), + )); + } + } + return Err(worktree_error( + WorktreeErrorCode::IoFailed, + format!("Failed to register worktree workspace: {track_error}"), + None, + )); + } + } + } else { + None + }; + + if is_idempotent_managed_create { + let session_id = request + .session_id + .as_deref() + .ok_or_else(|| "Idempotent worktree session requires a session ID".to_string())?; + let effective_path = desktop_effective_session_storage_path( + &app_state, + &project_workspace_path, + remote_conn.as_deref(), + remote_ssh_host.as_deref(), + ) + .await; + let existing = coordinator + .get_session_manager() + .load_session_metadata(&effective_path, session_id) + .await + .map_err(|error| format!("Failed to check existing worktree session: {error}"))?; + if let Some(metadata) = existing { + let target_matches = metadata.workspace_path.as_deref() + == Some(request.workspace_path.as_str()) + && metadata + .execution_target + .as_ref() + .and_then(|target| target.worktree_id.as_deref()) + == resolved_execution_target.worktree_id.as_deref(); + if !target_matches { + return Err(format!( + "Session ID {session_id} already exists with a different worktree target" + )); + } + let mut response = existing_session_create_response(&request, &metadata)?; + response.workspace_id = request.workspace_id.clone(); + return Ok(response); + } + } + if is_idempotent_review_create(&request) { let session_id = request .session_id @@ -1225,7 +1449,7 @@ pub async fn create_session( .ok_or_else(|| "Idempotent Review session requires a session ID".to_string())?; let effective_path = desktop_effective_session_storage_path( &app_state, - &request.workspace_path, + &project_workspace_path, remote_conn.as_deref(), remote_ssh_host.as_deref(), ) @@ -1289,6 +1513,8 @@ pub async fn create_session( max_turns: c.max_turns.unwrap_or(200), enable_context_compression: c.enable_context_compression.unwrap_or(true), workspace_path: Some(request.workspace_path.clone()), + project_workspace_path: Some(project_workspace_path.clone()), + execution_target: Some(resolved_execution_target.clone()), workspace_id: request.workspace_id.clone(), remote_connection_id: remote_conn.clone(), remote_ssh_host: remote_ssh_host.clone(), @@ -1297,6 +1523,8 @@ pub async fn create_session( }) .unwrap_or(SessionConfig { workspace_path: Some(request.workspace_path.clone()), + project_workspace_path: Some(project_workspace_path.clone()), + execution_target: Some(resolved_execution_target), workspace_id: request.workspace_id.clone(), remote_connection_id: remote_conn.clone(), remote_ssh_host: remote_ssh_host.clone(), @@ -1304,14 +1532,16 @@ pub async fn create_session( }); let session_kind = request.session_kind.unwrap_or_default(); - let session = if matches!(session_kind, SessionKind::Subagent) { + let execution_workspace_path = request.workspace_path.clone(); + let worktree_recovery_path = execution_workspace_path.clone(); + let create_result = if matches!(session_kind, SessionKind::Subagent) { coordinator .create_hidden_subagent_session_with_workspace( request.session_id, request.session_name.clone(), request.agent_type.clone(), config, - request.workspace_path, + execution_workspace_path, None, ) .await @@ -1322,11 +1552,50 @@ pub async fn create_session( request.session_name.clone(), request.agent_type.clone(), config, - request.workspace_path, + execution_workspace_path, ) .await - } - .map_err(|e| format!("Failed to create session: {}", e))?; + }; + let session = match create_result { + Ok(session) => session, + Err(create_error) => { + let mut rollback_issues = Vec::new(); + if let Some(workspace_id) = tracked_worktree_workspace_id.as_deref() { + if let Err(remove_error) = app_state + .workspace_service + .remove_workspace(workspace_id) + .await + { + warn!( + "Failed to remove rolled back worktree workspace registration: {}", + remove_error + ); + rollback_issues.push(format!( + "workspace registration could not be removed: {remove_error}" + )); + } + } + if let Some(worktree_id) = created_worktree_id.as_deref() { + if let Err(rollback_error) = + WorktreeService::rollback_created(&project_workspace_path, worktree_id).await + { + rollback_issues + .push(format!("worktree could not be removed: {rollback_error}")); + } + } + if !rollback_issues.is_empty() { + return Err(worktree_error( + WorktreeErrorCode::RollbackIncomplete, + format!( + "Failed to create session: {create_error}; {}", + rollback_issues.join("; ") + ), + Some(worktree_recovery_path), + )); + } + return Err(format!("Failed to create session: {create_error}")); + } + }; if let Some(relationship) = request.relationship { coordinator @@ -1360,6 +1629,10 @@ pub async fn create_session( session_id: session.session_id, session_name: session.session_name, agent_type: session.agent_type, + workspace_path: session.config.workspace_path, + workspace_id: session.config.workspace_id, + project_workspace_path: session.config.project_workspace_path, + execution_target: session.config.execution_target, }) } @@ -3044,6 +3317,9 @@ mod tests { session_name: "Review fixes".to_string(), agent_type: "CodeReview".to_string(), workspace_path: "/workspace".to_string(), + project_workspace_path: None, + execution_target: None, + request_id: None, workspace_id: None, session_kind: None, remote_connection_id: None, diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index d7c7160c26..0d4bc3538b 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -53,5 +53,6 @@ pub mod system_api; pub mod terminal_api; pub mod tool_api; pub mod workspace_activation; +pub mod worktree_api; pub use app_state::{AppState, AppStatistics, HealthStatus, RemoteWorkspace}; diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index bb3c606bc8..9545e5eea3 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1775,6 +1775,18 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "webdriver_bridge_result", RemoteWorkspacePolicy::LegacyUnaudited, ), + ("worktree_create", RemoteWorkspacePolicy::RemoteUnsupported), + ( + "worktree_create_branch", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ("worktree_list", RemoteWorkspacePolicy::RemoteUnsupported), + ("worktree_promote", RemoteWorkspacePolicy::RemoteUnsupported), + ( + "worktree_recreate", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ("worktree_remove", RemoteWorkspacePolicy::RemoteUnsupported), ("write_file_content", RemoteWorkspacePolicy::LegacyUnaudited), ]; diff --git a/src/apps/desktop/src/api/worktree_api.rs b/src/apps/desktop/src/api/worktree_api.rs new file mode 100644 index 0000000000..04d6296c61 --- /dev/null +++ b/src/apps/desktop/src/api/worktree_api.rs @@ -0,0 +1,76 @@ +//! Thin desktop adapter for product-owned managed worktrees. + +use bitfun_core::service::remote_ssh::lookup_remote_connection; +use bitfun_core::service::worktree::{ + WorktreeCreateBranchRequest, WorktreeCreateRequest, WorktreeCreateResult, WorktreeListRequest, + WorktreeMutationResult, WorktreePromoteRequest, WorktreeRecreateRequest, WorktreeRemoveRequest, + WorktreeRemoveResult, WorktreeService, +}; +use bitfun_core_types::{WorktreeError, WorktreeErrorCode, WorktreeSummary}; + +fn remote_unsupported() -> WorktreeError { + WorktreeError { + code: WorktreeErrorCode::RemoteUnsupported, + message: "Managed worktrees are not supported for remote SSH workspaces yet".to_string(), + recovery_path: None, + } +} + +async fn ensure_local(project_workspace_path: &str) -> Result<(), WorktreeError> { + if lookup_remote_connection(project_workspace_path) + .await + .is_some() + { + Err(remote_unsupported()) + } else { + Ok(()) + } +} + +#[tauri::command] +pub async fn worktree_list( + request: WorktreeListRequest, +) -> Result, WorktreeError> { + ensure_local(&request.project_workspace_path).await?; + WorktreeService::list(request).await +} + +#[tauri::command] +pub async fn worktree_create( + request: WorktreeCreateRequest, +) -> Result { + ensure_local(&request.project_workspace_path).await?; + WorktreeService::create(request).await +} + +#[tauri::command] +pub async fn worktree_create_branch( + request: WorktreeCreateBranchRequest, +) -> Result { + ensure_local(&request.project_workspace_path).await?; + WorktreeService::create_branch(request).await +} + +#[tauri::command] +pub async fn worktree_promote( + request: WorktreePromoteRequest, +) -> Result { + ensure_local(&request.project_workspace_path).await?; + WorktreeService::promote(request).await +} + +#[tauri::command] +pub async fn worktree_remove( + request: WorktreeRemoveRequest, +) -> Result { + ensure_local(&request.project_workspace_path).await?; + WorktreeService::remove(request).await +} + +#[tauri::command] +pub async fn worktree_recreate( + request: WorktreeRecreateRequest, +) -> Result { + ensure_local(&request.project_workspace_path).await?; + WorktreeService::recreate(request).await +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 07e4d95762..894f06d4b4 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1348,6 +1348,12 @@ pub async fn run() { git_list_worktrees, git_add_worktree, git_remove_worktree, + api::worktree_api::worktree_list, + api::worktree_api::worktree_create, + api::worktree_api::worktree_create_branch, + api::worktree_api::worktree_promote, + api::worktree_api::worktree_remove, + api::worktree_api::worktree_recreate, generate_commit_message, quick_commit_message, save_git_repo_history, diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 9cac4b5f31..9ade7a5638 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -1040,7 +1040,11 @@ impl ConversationCoordinator { return Some(binding); } - let binding = WorkspaceBinding::new(workspace_id, path_buf); + let mut binding = WorkspaceBinding::new(workspace_id, path_buf); + if let Some(project_workspace_path) = config.project_workspace_path.as_deref() { + binding = binding.with_project_root_path(PathBuf::from(project_workspace_path)); + } + binding = binding.with_execution_target(config.execution_target.clone()); Some(binding) } @@ -1809,6 +1813,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_name: session.session_name.clone(), agent_type: session.agent_type.clone(), workspace_path: Some(workspace_path), + project_workspace_path: session.config.project_workspace_path.clone(), + execution_target: session.config.execution_target.clone(), + workspace_id: session.config.workspace_id.clone(), remote_connection_id: session.config.remote_connection_id.clone(), remote_ssh_host: session.config.remote_ssh_host.clone(), }) @@ -1971,6 +1978,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet review_target_evidence: None, deep_review_cache: None, workspace_path: Some(workspace_path.to_string()), + project_workspace_path: Some(workspace_path.to_string()), + execution_target: None, workspace_hostname: None, unread_completion: None, needs_user_attention: None, @@ -8352,6 +8361,8 @@ async fn create_agent_session_from_runtime_request( request.agent_type, SessionConfig { workspace_path: Some(workspace_path.clone()), + project_workspace_path: request.project_workspace_path, + execution_target: request.execution_target, workspace_id: request.workspace_id, remote_connection_id: request.remote_connection_id, remote_ssh_host: request.remote_ssh_host, @@ -8532,6 +8543,8 @@ fn runtime_session_workspace_binding(binding: WorkspaceBinding) -> AgentSessionW AgentSessionWorkspaceBinding { workspace_id: binding.workspace_id.clone(), workspace_path: binding.root_path_string(), + project_workspace_path: Some(binding.project_root_path_string()), + execution_target: binding.execution_target.clone(), remote_connection_id: binding.connection_id().map(ToOwned::to_owned), remote_ssh_host: if binding.is_remote() { Some(binding.session_identity.hostname.clone()).filter(|value| !value.trim().is_empty()) @@ -10350,6 +10363,8 @@ mod tests { session_name: "Worker".to_string(), agent_type: "agentic".to_string(), workspace_path: Some(workspace_path.to_string_lossy().into_owned()), + project_workspace_path: None, + execution_target: None, workspace_id: Some("workspace-1".to_string()), remote_connection_id: None, remote_ssh_host: None, @@ -10387,6 +10402,8 @@ mod tests { session_name: "Original".to_string(), agent_type: "agentic".to_string(), workspace_path: Some(workspace.clone()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -10482,6 +10499,8 @@ mod tests { session_name: "Over capacity".to_string(), agent_type: "agentic".to_string(), workspace_path: Some(std::env::temp_dir().to_string_lossy().into_owned()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -10511,6 +10530,8 @@ mod tests { session_name: "Fixed worker".to_string(), agent_type: "agentic".to_string(), workspace_path: Some(workspace_path.to_string_lossy().into_owned()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -10543,6 +10564,8 @@ mod tests { session_name: "Duplicate worker".to_string(), agent_type: "agentic".to_string(), workspace_path: Some(workspace_path.to_string_lossy().into_owned()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -10882,6 +10905,8 @@ mod tests { session_name: name.to_string(), agent_type: "agentic".to_string(), workspace_path: Some(workspace.clone()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -11013,6 +11038,8 @@ mod tests { session_name: "Invalid worker".to_string(), agent_type: "agentic".to_string(), workspace_path: Some(std::env::temp_dir().to_string_lossy().into_owned()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index d16a27f6d4..1073da085e 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -804,6 +804,8 @@ impl PersistenceManager { turn_count: session.dialog_turn_ids.len(), snapshot_session_id: session.snapshot_session_id.as_deref(), workspace_path: &workspace_root, + project_workspace_path: session.config.project_workspace_path.as_deref(), + execution_target: session.config.execution_target.as_ref(), workspace_hostname: workspace_hostname.as_deref(), new_session_memory_mode: new_session_memory_mode_from_global_config().await, existing, diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index d0264cb2b0..b33e0016b3 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -766,8 +766,12 @@ impl SessionManager { let runtime_service = persistence_manager.runtime_service(); Some(if identity.hostname == LOCAL_WORKSPACE_SSH_HOST { + let project_workspace_path = config + .project_workspace_path + .as_deref() + .unwrap_or_else(|| identity.logical_workspace_path()); runtime_service - .context_for_local_workspace(Path::new(identity.logical_workspace_path())) + .context_for_local_workspace(Path::new(project_workspace_path)) .sessions_dir } else if identity.hostname == "_unresolved" { bitfun_services_integrations::remote_ssh::unresolved_remote_session_storage_dir( @@ -1186,6 +1190,8 @@ impl SessionManager { let mut config = SessionConfig { workspace_path: Some(workspace_path.clone()), + project_workspace_path: metadata.project_workspace_path.clone(), + execution_target: metadata.execution_target.clone(), ..SessionConfig::default() }; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs index 0bd0abf83c..4aa05675fb 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs @@ -135,6 +135,8 @@ impl CronTool { return Ok(CronWorkspaceRef { workspace_id: None, workspace_path: resolved, + project_workspace_path: None, + execution_target: None, remote_connection_id: None, remote_ssh_host: None, }); @@ -162,7 +164,10 @@ impl CronTool { ) -> BitFunResult<()> { let sessions = runtime .list_sessions(AgentSessionListRequest { - workspace_path: workspace_ref.workspace_path.clone(), + workspace_path: workspace_ref + .project_workspace_path + .clone() + .unwrap_or_else(|| workspace_ref.workspace_path.clone()), remote_connection_id: workspace_ref.remote_connection_id.clone(), remote_ssh_host: workspace_ref.remote_ssh_host.clone(), }) @@ -227,6 +232,8 @@ impl CronTool { CronWorkspaceRef { workspace_id: binding.workspace_id.clone(), workspace_path: binding.root_path_string(), + project_workspace_path: Some(binding.project_root_path_string()), + execution_target: binding.execution_target.clone(), remote_connection_id: binding.connection_id().map(ToOwned::to_owned), remote_ssh_host: if binding.is_remote() { Some(binding.session_identity.hostname.clone()) @@ -241,6 +248,8 @@ impl CronTool { CronWorkspaceRef { workspace_id: binding.workspace_id, workspace_path: binding.workspace_path, + project_workspace_path: binding.project_workspace_path, + execution_target: binding.execution_target, remote_connection_id: binding.remote_connection_id, remote_ssh_host: binding.remote_ssh_host, } @@ -1366,6 +1375,8 @@ mod tests { CronTool::workspace_ref_from_agent_binding(AgentSessionWorkspaceBinding { workspace_id: Some("workspace-1".to_string()), workspace_path: "/home/wsp/projects/test".to_string(), + project_workspace_path: None, + execution_target: None, remote_connection_id: Some("conn-1".to_string()), remote_ssh_host: Some("ssh.dev".to_string()), }); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index 9e0c9bd7c5..9976283260 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -45,6 +45,7 @@ pub mod todo_write_tool; pub mod util; pub mod view_image_tool; pub mod web; +pub mod worktree_tool; #[deprecated(note = "GetToolSpecTool is owned by the product tool runtime boundary")] pub use crate::agentic::tools::product_runtime::GetToolSpecTool; @@ -90,3 +91,4 @@ pub use thread_goal_tools::{CreateGoalTool, GetGoalTool, UpdateGoalTool}; pub use todo_write_tool::TodoWriteTool; pub use view_image_tool::ViewImageTool; pub use web::{WebFetchTool, WebSearchTool}; +pub use worktree_tool::WorktreeTool; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index bff2ff3b64..c57f3cde62 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -22,6 +22,7 @@ use bitfun_agent_runtime::session_control::{ SessionControlAction, SessionControlCancelRoute, SessionControlInput, SessionControlValidationContext, SessionControlValidationResult, }; +use bitfun_core_types::SessionExecutionTarget; use bitfun_runtime_ports::{ AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionListRequest, AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, @@ -38,6 +39,9 @@ const CANCEL_WAIT_TIMEOUT: Duration = Duration::from_secs(3); #[derive(Debug, Clone)] struct SessionControlWorkspaceTarget { display_workspace: String, + project_workspace: String, + execution_target: Option, + workspace_id: Option, remote_connection_id: Option, remote_ssh_host: Option, } @@ -134,6 +138,9 @@ impl SessionControlTool { ) -> SessionControlWorkspaceTarget { SessionControlWorkspaceTarget { display_workspace: normalize_path(&workspace.root_path_string()), + project_workspace: normalize_path(&workspace.project_root_path_string()), + execution_target: workspace.execution_target.clone(), + workspace_id: workspace.workspace_id.clone(), remote_connection_id: workspace.connection_id().map(ToOwned::to_owned), remote_ssh_host: if workspace.is_remote() { Some(workspace.session_identity.hostname.clone()) @@ -147,8 +154,15 @@ impl SessionControlTool { fn workspace_target_from_binding( binding: AgentSessionWorkspaceBinding, ) -> SessionControlWorkspaceTarget { + let project_workspace = binding + .project_workspace_path + .clone() + .unwrap_or_else(|| binding.workspace_path.clone()); SessionControlWorkspaceTarget { display_workspace: binding.workspace_path, + project_workspace, + execution_target: binding.execution_target, + workspace_id: binding.workspace_id, remote_connection_id: binding.remote_connection_id, remote_ssh_host: binding.remote_ssh_host, } @@ -178,7 +192,7 @@ impl SessionControlTool { ) -> BitFunResult<()> { let existing_sessions = runtime .list_sessions(AgentSessionListRequest { - workspace_path: workspace.display_workspace.clone(), + workspace_path: workspace.project_workspace.clone(), remote_connection_id: workspace.remote_connection_id.clone(), remote_ssh_host: workspace.remote_ssh_host.clone(), }) @@ -375,7 +389,9 @@ Arguments: session_name, agent_type, workspace_path: Some(workspace.display_workspace.clone()), - workspace_id: None, + project_workspace_path: Some(workspace.project_workspace.clone()), + execution_target: workspace.execution_target.clone(), + workspace_id: workspace.workspace_id.clone(), remote_connection_id: workspace.remote_connection_id.clone(), remote_ssh_host: workspace.remote_ssh_host.clone(), model_id: None, @@ -529,7 +545,7 @@ Arguments: deletion_runtime .delete_session(AgentSessionDeleteRequest { - workspace_path: workspace.display_workspace.clone(), + workspace_path: workspace.project_workspace.clone(), session_id: session_id.to_string(), remote_connection_id: workspace.remote_connection_id.clone(), remote_ssh_host: workspace.remote_ssh_host.clone(), @@ -564,7 +580,7 @@ Arguments: .await?; let sessions = runtime .list_sessions(AgentSessionListRequest { - workspace_path: workspace.display_workspace.clone(), + workspace_path: workspace.project_workspace.clone(), remote_connection_id: workspace.remote_connection_id.clone(), remote_ssh_host: workspace.remote_ssh_host.clone(), }) @@ -601,6 +617,10 @@ Arguments: mod tests { use super::*; use crate::agentic::tools::framework::ToolUseContext; + use crate::agentic::WorkspaceBinding; + use bitfun_core_types::{ + SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, + }; use serde_json::json; use std::collections::HashMap; use std::fs; @@ -645,6 +665,28 @@ mod tests { } } + #[test] + fn worktree_context_keeps_project_scope_for_session_operations() { + let execution_target = SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("wt-1".to_string()), + root_path: "/worktrees/wt-1".to_string(), + base_ref: Some("HEAD".to_string()), + base_commit: Some("0123456789abcdef".to_string()), + branch: None, + lifecycle: Some(WorktreeLifecycle::Managed), + }; + let binding = WorkspaceBinding::new(None, PathBuf::from("/worktrees/wt-1")) + .with_project_root_path(PathBuf::from("/repo")) + .with_execution_target(Some(execution_target.clone())); + + let target = SessionControlTool::workspace_target_from_context(&binding); + + assert_eq!(target.display_workspace, "/worktrees/wt-1"); + assert_eq!(target.project_workspace, "/repo"); + assert_eq!(target.execution_target, Some(execution_target)); + } + #[tokio::test] async fn validate_cancel_requires_session_id() { let tool = SessionControlTool::new(); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index 1ca8bd6092..4b726628fb 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -9,6 +9,7 @@ use crate::agentic::tools::workspace_paths::posix_style_path_is_absolute; use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; +use bitfun_core_types::SessionExecutionTarget; use bitfun_runtime_ports::{ AgentDialogPrependedReminder, AgentDialogTurnRequest, AgentSessionCreateRequest, AgentSessionListRequest, AgentSessionReplyRoute, AgentSessionSummary, @@ -24,6 +25,9 @@ pub struct SessionMessageTool; #[derive(Debug, Clone)] struct SessionMessageWorkspaceTarget { workspace_path: String, + project_workspace_path: String, + execution_target: Option, + workspace_id: Option, remote_connection_id: Option, remote_ssh_host: Option, } @@ -173,18 +177,32 @@ impl SessionMessageTool { workspace_path: String, context: &ToolUseContext, ) -> SessionMessageWorkspaceTarget { - let remote_connection_id = context - .workspace - .as_ref() - .and_then(|workspace| workspace.connection_id().map(ToOwned::to_owned)); - let remote_ssh_host = context - .workspace - .as_ref() + let binding = context.workspace.as_ref(); + let inherits_current_target = binding.is_some_and(|binding| { + normalize_path(&binding.root_path_string()) == normalize_path(&workspace_path) + }); + let remote_connection_id = + binding.and_then(|workspace| workspace.connection_id().map(ToOwned::to_owned)); + let remote_ssh_host = binding .filter(|workspace| workspace.is_remote()) .map(|workspace| workspace.session_identity.hostname.clone()) .filter(|value| !value.trim().is_empty()); + let project_workspace_path = if inherits_current_target { + binding + .map(|workspace| normalize_path(&workspace.project_root_path_string())) + .unwrap_or_else(|| workspace_path.clone()) + } else { + workspace_path.clone() + }; SessionMessageWorkspaceTarget { workspace_path, + project_workspace_path, + execution_target: binding + .filter(|_| inherits_current_target) + .and_then(|workspace| workspace.execution_target.clone()), + workspace_id: binding + .filter(|_| inherits_current_target) + .and_then(|workspace| workspace.workspace_id.clone()), remote_connection_id, remote_ssh_host, } @@ -194,8 +212,15 @@ impl SessionMessageTool { &self, binding: AgentSessionWorkspaceBinding, ) -> SessionMessageWorkspaceTarget { + let project_workspace_path = binding + .project_workspace_path + .clone() + .unwrap_or_else(|| binding.workspace_path.clone()); SessionMessageWorkspaceTarget { workspace_path: binding.workspace_path, + project_workspace_path, + execution_target: binding.execution_target, + workspace_id: binding.workspace_id, remote_connection_id: binding.remote_connection_id, remote_ssh_host: binding.remote_ssh_host, } @@ -565,7 +590,7 @@ Allowed agent types when creating a session: let visible_sessions = runtime .list_sessions(AgentSessionListRequest { - workspace_path: workspace_target.workspace_path.clone(), + workspace_path: workspace_target.project_workspace_path.clone(), remote_connection_id: workspace_target.remote_connection_id.clone(), remote_ssh_host: workspace_target.remote_ssh_host.clone(), }) @@ -632,7 +657,11 @@ Allowed agent types when creating a session: session_name, agent_type: agent_type.clone(), workspace_path: Some(workspace_target.workspace_path.clone()), - workspace_id: None, + project_workspace_path: Some( + workspace_target.project_workspace_path.clone(), + ), + execution_target: workspace_target.execution_target.clone(), + workspace_id: workspace_target.workspace_id.clone(), remote_connection_id: workspace_target.remote_connection_id.clone(), remote_ssh_host: workspace_target.remote_ssh_host.clone(), model_id: None, @@ -708,6 +737,10 @@ Allowed agent types when creating a session: mod tests { use super::*; use crate::agentic::tools::framework::ToolUseContext; + use crate::agentic::WorkspaceBinding; + use bitfun_core_types::{ + SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, + }; use serde_json::json; use std::collections::HashMap; use std::fs; @@ -766,11 +799,39 @@ mod tests { ) -> SessionMessageWorkspaceTarget { SessionMessageWorkspaceTarget { workspace_path: workspace_path.to_string(), + project_workspace_path: workspace_path.to_string(), + execution_target: None, + workspace_id: None, remote_connection_id: remote_connection_id.map(ToOwned::to_owned), remote_ssh_host: remote_ssh_host.map(ToOwned::to_owned), } } + #[test] + fn creating_in_current_worktree_inherits_project_scope_and_target() { + let execution_target = SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("wt-1".to_string()), + root_path: "/worktrees/wt-1".to_string(), + base_ref: Some("HEAD".to_string()), + base_commit: Some("0123456789abcdef".to_string()), + branch: None, + lifecycle: Some(WorktreeLifecycle::Managed), + }; + let binding = WorkspaceBinding::new(None, PathBuf::from("/worktrees/wt-1")) + .with_project_root_path(PathBuf::from("/repo")) + .with_execution_target(Some(execution_target.clone())); + let mut context = empty_context(); + context.workspace = Some(binding); + + let target = SessionMessageTool::new() + .workspace_target_from_context("/worktrees/wt-1".to_string(), &context); + + assert_eq!(target.workspace_path, "/worktrees/wt-1"); + assert_eq!(target.project_workspace_path, "/repo"); + assert_eq!(target.execution_target, Some(execution_target)); + } + #[test] fn workspace_identity_matches_full_remote_tuple() { let left = workspace_target("/root/repo", Some("conn-1"), Some("host-a")); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs new file mode 100644 index 0000000000..7932eaf219 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs @@ -0,0 +1,617 @@ +//! Deferred Agent tool for safe project-scoped worktree orchestration. + +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::tools::framework::{ + PermissionIntent, Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, + ValidationResult, +}; +use crate::service::workspace::{ + get_global_workspace_service, WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceService, +}; +use crate::service::worktree::{ + WorktreeCreateBranchRequest, WorktreeCreateRequest, WorktreeCreateResult, WorktreeListRequest, + WorktreeRemoveRequest, WorktreeService, +}; +use crate::service_agent_runtime::CoreServiceAgentRuntime; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use bitfun_agent_runtime::session_control::session_control_creator_marker; +use bitfun_runtime_ports::{ + AgentSessionCreateRequest, AgentSessionListRequest, AgentSessionWorkspaceRequest, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +enum WorktreeToolOperation { + List, + CreateSession, + CreateBranch, + Remove, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct WorktreeToolInput { + operation: WorktreeToolOperation, + #[serde(default)] + worktree_id: Option, + #[serde(default)] + base_ref: Option, + #[serde(default)] + copy_local_changes: bool, + #[serde(default)] + branch: Option, + #[serde(default)] + session_name: Option, + #[serde(default)] + agent_type: Option, +} + +pub struct WorktreeTool; + +impl WorktreeTool { + pub fn new() -> Self { + Self + } + + fn project_path(context: &ToolUseContext) -> BitFunResult { + let workspace = context.workspace.as_ref().ok_or_else(|| { + BitFunError::tool("Worktree requires a workspace-bound session".to_string()) + })?; + if workspace.is_remote() { + return Err(BitFunError::tool( + "Managed worktrees are not supported for remote SSH workspaces yet".to_string(), + )); + } + context + .project_workspace_root() + .map(|path| path.to_string_lossy().to_string()) + .ok_or_else(|| BitFunError::tool("Project workspace root is unavailable".to_string())) + } + + fn request_id(context: &ToolUseContext, operation: &str) -> String { + context + .tool_call_id + .as_deref() + .map(|tool_call_id| format!("agent:{tool_call_id}:{operation}")) + .unwrap_or_else(|| format!("agent:{}:{operation}", uuid::Uuid::new_v4())) + } + + fn same_path(left: &Path, right: &Path) -> bool { + let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf()); + let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf()); + left == right + } + + async fn ensure_not_current_worktree( + context: &ToolUseContext, + project_workspace_path: &str, + worktree_id: &str, + ) -> BitFunResult<()> { + let current_root = context.workspace_root().ok_or_else(|| { + BitFunError::tool("Current workspace root is unavailable".to_string()) + })?; + let worktree = WorktreeService::list(WorktreeListRequest { + project_workspace_path: project_workspace_path.to_string(), + }) + .await + .map_err(|error| BitFunError::tool(error.to_string()))? + .into_iter() + .find(|worktree| worktree.worktree_id == worktree_id) + .ok_or_else(|| BitFunError::NotFound("Worktree was not found".to_string()))?; + if Self::same_path(current_root, Path::new(&worktree.path)) { + return Err(BitFunError::tool( + "Worktree cannot remove or rebind the worktree running this tool".to_string(), + )); + } + Ok(()) + } + + async fn cleanup_failed_fresh_create( + project_workspace_path: &str, + created: &WorktreeCreateResult, + workspace_service: &WorkspaceService, + tracked_workspace_id: Option<&str>, + failure: impl Into, + ) -> BitFunError { + let failure = failure.into(); + if !created.created { + return BitFunError::tool(failure); + } + + let mut rollback_issues = Vec::new(); + if let Some(workspace_id) = tracked_workspace_id { + if let Err(remove_error) = workspace_service.remove_workspace(workspace_id).await { + rollback_issues.push(format!( + "workspace registration could not be removed: {remove_error}" + )); + } + } + if let Some(worktree_id) = created.execution_target.worktree_id.as_deref() { + if let Err(rollback_error) = + WorktreeService::rollback_created(project_workspace_path, worktree_id).await + { + rollback_issues.push(format!("worktree could not be removed: {rollback_error}")); + } + } + if rollback_issues.is_empty() { + BitFunError::tool(failure) + } else { + BitFunError::tool(format!( + "rollback_incomplete: {failure}; {}; recovery_path={}", + rollback_issues.join("; "), + created.execution_target.root_path + )) + } + } +} + +impl Default for WorktreeTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for WorktreeTool { + fn name(&self) -> &str { + "Worktree" + } + + async fn description(&self) -> BitFunResult { + Ok( + r#"Manage isolated Git worktrees for the current main project. + +Actions: +- "list": Read worktrees and associated sessions. +- "create_session": Create a managed detached worktree and a new persisted session. +- "create_branch": Create a branch for a detached managed worktree. +- "remove": Safely remove a managed worktree. This never forces removal. + +The tool cannot remove or rebind the worktree in which it is running. Use SessionMessage after create_session to delegate work to the returned session_id."# + .to_string(), + ) + } + + fn short_description(&self) -> String { + "List worktrees or safely create isolated sessions, branches, and removals.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + + async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { + context + .and_then(|context| context.workspace.as_ref()) + .is_some_and(|workspace| !workspace.is_remote()) + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["list", "create_session", "create_branch", "remove"] + }, + "worktree_id": { + "type": "string", + "description": "Required for create_branch and remove. Arbitrary paths are not accepted." + }, + "base_ref": { + "type": "string", + "description": "Optional Git ref for create_session. Defaults to HEAD." + }, + "copy_local_changes": { + "type": "boolean", + "default": false, + "description": "Copy staged, unstaged, untracked, and .worktreeinclude-selected ignored files when the base equals source HEAD." + }, + "branch": { + "type": "string", + "description": "Required for create_branch." + }, + "session_name": { + "type": "string", + "description": "Optional display name for create_session." + }, + "agent_type": { + "type": "string", + "enum": ["agentic", "Plan", "Cowork"], + "description": "Optional mode for create_session. Defaults to agentic." + } + }, + "required": ["operation"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn permission_intents( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let input: WorktreeToolInput = serde_json::from_value(input.clone()) + .map_err(|error| BitFunError::validation(format!("Invalid input: {error}")))?; + let project = Self::project_path(context)?; + let intent = match input.operation { + WorktreeToolOperation::List => return Ok(Vec::new()), + WorktreeToolOperation::CreateSession => { + PermissionIntent::new("worktree.create", vec![project]) + } + WorktreeToolOperation::CreateBranch => PermissionIntent::new( + "worktree.branch", + vec![format!( + "{}:{}", + project, + input.worktree_id.as_deref().unwrap_or_default() + )], + ), + WorktreeToolOperation::Remove => PermissionIntent::new( + "worktree.remove", + vec![format!( + "{}:{}", + project, + input.worktree_id.as_deref().unwrap_or_default() + )], + ), + }; + Ok(vec![intent]) + } + + async fn validate_input( + &self, + input: &Value, + context: Option<&ToolUseContext>, + ) -> ValidationResult { + let parsed: WorktreeToolInput = match serde_json::from_value(input.clone()) { + Ok(parsed) => parsed, + Err(error) => { + return ValidationResult { + result: false, + message: Some(format!("Invalid input: {error}")), + error_code: Some(400), + meta: None, + } + } + }; + let Some(context) = context else { + return ValidationResult { + result: false, + message: Some("Worktree requires tool context".to_string()), + error_code: Some(400), + meta: None, + }; + }; + if let Err(error) = Self::project_path(context) { + return ValidationResult { + result: false, + message: Some(error.to_string()), + error_code: Some(400), + meta: None, + }; + } + let missing = match parsed.operation { + WorktreeToolOperation::CreateBranch => { + parsed.worktree_id.as_deref().is_none_or(str::is_empty) + || parsed.branch.as_deref().is_none_or(str::is_empty) + } + WorktreeToolOperation::Remove => { + parsed.worktree_id.as_deref().is_none_or(str::is_empty) + } + WorktreeToolOperation::List | WorktreeToolOperation::CreateSession => false, + }; + if missing { + return ValidationResult { + result: false, + message: Some( + "worktree_id is required for remove; worktree_id and branch are required for create_branch" + .to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + ValidationResult::default() + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let operation = input + .get("operation") + .and_then(Value::as_str) + .unwrap_or("manage"); + format!("Worktree {operation}") + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let input: WorktreeToolInput = serde_json::from_value(input.clone()) + .map_err(|error| BitFunError::tool(format!("Invalid input: {error}")))?; + let project_workspace_path = Self::project_path(context)?; + + let data = match input.operation { + WorktreeToolOperation::List => { + let worktrees = WorktreeService::list(WorktreeListRequest { + project_workspace_path: project_workspace_path.clone(), + }) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ + "success": true, + "operation": "list", + "project_workspace_path": project_workspace_path, + "count": worktrees.len(), + "worktrees": worktrees, + }) + } + WorktreeToolOperation::CreateSession => { + let operation_request_id = Self::request_id(context, "create_session"); + let stable_session_id = + WorktreeService::session_id_for_request(&operation_request_id) + .map_err(|error| BitFunError::tool(error.to_string()))?; + let source_workspace_path = context + .workspace_root() + .ok_or_else(|| { + BitFunError::tool("Current execution workspace is unavailable".to_string()) + })? + .to_string_lossy() + .to_string(); + let created = WorktreeService::create(WorktreeCreateRequest { + request_id: operation_request_id, + project_workspace_path: project_workspace_path.clone(), + source_workspace_path: Some(source_workspace_path), + base_ref: input.base_ref, + copy_local_changes: input.copy_local_changes, + }) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + + let workspace_service = get_global_workspace_service().ok_or_else(|| { + BitFunError::tool("Workspace service is not initialized".to_string()) + })?; + let tracked_workspace = match workspace_service + .track_workspace_activity( + PathBuf::from(&created.execution_target.root_path), + WorkspaceCreateOptions::default(), + WorkspaceActivityMode::RefreshMetadata, + ) + .await + { + Ok(workspace) => workspace, + Err(track_error) => { + return Err(Self::cleanup_failed_fresh_create( + &project_workspace_path, + &created, + workspace_service.as_ref(), + None, + format!("Failed to register worktree workspace: {track_error}"), + ) + .await); + } + }; + + let coordinator = match get_global_coordinator() { + Some(coordinator) => coordinator, + None => { + return Err(Self::cleanup_failed_fresh_create( + &project_workspace_path, + &created, + workspace_service.as_ref(), + Some(&tracked_workspace.id), + "Coordinator is not initialized", + ) + .await); + } + }; + let runtime = match CoreServiceAgentRuntime::agent_runtime(coordinator) { + Ok(runtime) => runtime, + Err(runtime_error) => { + return Err(Self::cleanup_failed_fresh_create( + &project_workspace_path, + &created, + workspace_service.as_ref(), + Some(&tracked_workspace.id), + runtime_error, + ) + .await); + } + }; + let existing = match runtime + .list_sessions(AgentSessionListRequest { + workspace_path: project_workspace_path.clone(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + { + Ok(sessions) => sessions, + Err(list_error) => { + return Err(Self::cleanup_failed_fresh_create( + &project_workspace_path, + &created, + workspace_service.as_ref(), + Some(&tracked_workspace.id), + list_error.into_message(), + ) + .await); + } + } + .into_iter() + .find(|session| session.session_id == stable_session_id); + if let Some(existing) = existing { + let binding = match runtime + .resolve_session_workspace_binding(AgentSessionWorkspaceRequest { + session_id: stable_session_id.clone(), + }) + .await + { + Ok(binding) => binding, + Err(binding_error) => { + return Err(Self::cleanup_failed_fresh_create( + &project_workspace_path, + &created, + workspace_service.as_ref(), + Some(&tracked_workspace.id), + binding_error.into_message(), + ) + .await); + } + }; + if binding + .as_ref() + .map(|binding| binding.workspace_path.as_str()) + != Some(created.execution_target.root_path.as_str()) + { + return Err(Self::cleanup_failed_fresh_create( + &project_workspace_path, + &created, + workspace_service.as_ref(), + Some(&tracked_workspace.id), + "Idempotent worktree request resolved to a session with a different execution target", + ) + .await); + } + let replay_data = json!({ + "success": true, + "operation": "create_session", + "worktree_id": created.execution_target.worktree_id, + "path": created.execution_target.root_path, + "session_id": existing.session_id, + "session_name": existing.session_name, + "idempotent_replay": true, + "next": "Use SessionMessage with session_id to delegate work.", + }); + return Ok(vec![ToolResult::Result { + result_for_assistant: Some(replay_data.to_string()), + data: replay_data, + image_attachments: None, + }]); + } + let mut metadata = serde_json::Map::new(); + if let Some(session_id) = context.session_id.as_deref() { + metadata.insert( + "createdBy".to_string(), + json!(session_control_creator_marker(session_id)), + ); + } + let session = match runtime + .create_session_with_id( + stable_session_id, + AgentSessionCreateRequest { + session_name: input + .session_name + .unwrap_or_else(|| "New Worktree Session".to_string()), + agent_type: input.agent_type.unwrap_or_else(|| "agentic".to_string()), + workspace_path: Some(created.execution_target.root_path.clone()), + project_workspace_path: Some(project_workspace_path.clone()), + execution_target: Some(created.execution_target.clone()), + workspace_id: Some(tracked_workspace.id.clone()), + remote_connection_id: None, + remote_ssh_host: None, + model_id: None, + metadata, + }, + ) + .await + { + Ok(session) => session, + Err(create_error) => { + return Err(Self::cleanup_failed_fresh_create( + &project_workspace_path, + &created, + workspace_service.as_ref(), + Some(&tracked_workspace.id), + format!("Failed to create worktree session: {create_error}"), + ) + .await); + } + }; + json!({ + "success": true, + "operation": "create_session", + "worktree_id": created.execution_target.worktree_id, + "path": created.execution_target.root_path, + "session_id": session.session_id, + "session_name": session.session_name, + "next": "Use SessionMessage with session_id to delegate work.", + }) + } + WorktreeToolOperation::CreateBranch => { + let worktree_id = input.worktree_id.as_deref().unwrap_or_default(); + Self::ensure_not_current_worktree(context, &project_workspace_path, worktree_id) + .await?; + let result = WorktreeService::create_branch(WorktreeCreateBranchRequest { + request_id: Self::request_id(context, "create_branch"), + project_workspace_path, + worktree_id: worktree_id.to_string(), + branch: input.branch.unwrap_or_default(), + }) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ + "success": true, + "operation": "create_branch", + "worktree": result.worktree, + }) + } + WorktreeToolOperation::Remove => { + let worktree_id = input.worktree_id.as_deref().unwrap_or_default(); + Self::ensure_not_current_worktree(context, &project_workspace_path, worktree_id) + .await?; + let result = WorktreeService::remove(WorktreeRemoveRequest { + request_id: Self::request_id(context, "remove"), + project_workspace_path, + worktree_id: worktree_id.to_string(), + force: false, + }) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ + "success": result.removed, + "operation": "remove", + "worktree_id": result.worktree_id, + }) + } + }; + + Ok(vec![ToolResult::Result { + result_for_assistant: Some(data.to_string()), + data, + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::WorktreeTool; + use crate::agentic::tools::framework::Tool; + use serde_json::json; + + #[test] + fn worktree_tool_is_deferred_and_does_not_accept_force_or_paths() { + let tool = WorktreeTool::new(); + let schema = tool.input_schema(); + let properties = schema["properties"] + .as_object() + .expect("properties should be an object"); + assert!(!properties.contains_key("force")); + assert!(!properties.contains_key("path")); + assert_eq!(format!("{:?}", tool.default_exposure()), "Deferred"); + assert!(schema["properties"]["operation"]["enum"] + .as_array() + .expect("operation enum") + .contains(&json!("remove"))); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index a6761b0e67..271f9a7eb4 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -64,6 +64,7 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "GetMCPPrompt" => Some(Arc::new(GetMCPPromptTool::new())), "GenerativeUI" => Some(Arc::new(GenerativeUITool::new())), "Git" => Some(Arc::new(GitTool::new())), + "Worktree" => Some(Arc::new(WorktreeTool::new())), "ReviewPlatform" => Some(Arc::new(ReviewPlatformTool::new())), "InitMiniApp" => Some(Arc::new(InitMiniAppTool::new())), "PageDeploy" => Some(Arc::new(PageDeployTool::new())), diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 7c89bbb165..b0857e5ceb 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -541,6 +541,7 @@ mod tests { "GetMCPPrompt", "GenerativeUI", "Git", + "Worktree", "ReviewPlatform", "InitMiniApp", "PageDeploy", @@ -695,6 +696,7 @@ mod tests { assert!(registry.is_tool_deferred("ListModels")); assert!(!registry.is_tool_deferred("GetToolSpec")); assert!(registry.is_tool_deferred("Git")); + assert!(registry.is_tool_deferred("Worktree")); assert!(registry.is_tool_deferred("ReviewPlatform")); assert!(!registry.is_tool_deferred("InitMiniApp")); } @@ -721,6 +723,7 @@ mod tests { "GetMCPPrompt", "GenerativeUI", "Git", + "Worktree", "ReviewPlatform", "ControlHub", "ComputerUse", diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index 4341b1c374..732eeb2c91 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -87,6 +87,14 @@ impl ToolUseContext { self.workspace.as_ref().map(|binding| binding.root_path()) } + /// Main project root used by project-scoped orchestration tools. File, + /// terminal, and Git tools must continue to use [`Self::workspace_root`]. + pub fn project_workspace_root(&self) -> Option<&Path> { + self.workspace + .as_ref() + .map(|binding| binding.project_root_path()) + } + pub fn is_remote(&self) -> bool { self.workspace .as_ref() diff --git a/src/crates/assembly/core/src/agentic/workspace.rs b/src/crates/assembly/core/src/agentic/workspace.rs index c00ab39a5d..7bb17bbae9 100644 --- a/src/crates/assembly/core/src/agentic/workspace.rs +++ b/src/crates/assembly/core/src/agentic/workspace.rs @@ -1,5 +1,6 @@ use crate::service::remote_ssh::workspace_state::WorkspaceSessionIdentity; use crate::service::workspace_runtime::WorkspaceRuntimeService; +use bitfun_core_types::SessionExecutionTarget; pub use bitfun_runtime_ports::{ WorkspaceCommandOptions, WorkspaceCommandResult, WorkspaceDirEntry, WorkspaceFileSystem, WorkspaceServices, WorkspaceShell, @@ -29,6 +30,11 @@ pub struct WorkspaceBinding { /// For local workspaces this is a local path; for remote workspaces it is /// the path on the remote server (e.g. `/root/project`). pub root_path: PathBuf, + /// Main project root used for persistence and product-level orchestration. + /// It equals `root_path` for legacy, local, and remote sessions. + pub project_root_path: PathBuf, + /// Resolved execution target persisted with the session. + pub execution_target: Option, pub backend: WorkspaceBackend, /// Unified identity for session persistence. Local and remote workspaces /// share the same model; the only semantic difference is hostname. @@ -52,6 +58,8 @@ impl WorkspaceBinding { }); Self { workspace_id, + project_root_path: root_path.clone(), + execution_target: None, root_path, backend: WorkspaceBackend::Local, session_identity, @@ -67,6 +75,8 @@ impl WorkspaceBinding { ) -> Self { Self { workspace_id, + project_root_path: root_path.clone(), + execution_target: None, root_path, backend: WorkspaceBackend::Remote { connection_id, @@ -84,6 +94,31 @@ impl WorkspaceBinding { self.root_path.to_string_lossy().to_string() } + pub fn project_root_path(&self) -> &Path { + &self.project_root_path + } + + pub fn project_root_path_string(&self) -> String { + self.project_root_path.to_string_lossy().to_string() + } + + /// Binds a local execution root to the main project that owns its session + /// data. Remote workspaces intentionally keep a single root. + pub fn with_project_root_path(mut self, project_root_path: PathBuf) -> Self { + if !self.is_remote() { + self.project_root_path = project_root_path; + } + self + } + + pub fn with_execution_target( + mut self, + execution_target: Option, + ) -> Self { + self.execution_target = execution_target; + self + } + /// Logical workspace root used by tools, display, and workspace-bound IO. /// /// For local workspaces this is the local project root. For remote SSH @@ -129,7 +164,7 @@ impl WorkspaceBinding { } runtime_service - .context_for_local_workspace(self.logical_workspace_path()) + .context_for_local_workspace(self.project_root_path()) .sessions_dir } } @@ -140,6 +175,10 @@ mod tests { use crate::service::remote_ssh::workspace_state::{ remote_workspace_session_mirror_dir, workspace_session_identity, }; + use crate::service::workspace_runtime::WorkspaceRuntimeService; + use bitfun_core_types::{ + SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, + }; use std::path::PathBuf; #[test] @@ -164,6 +203,35 @@ mod tests { remote_workspace_session_mirror_dir("127.0.0.1", "/home/wsp/projects/test") ); } + + #[test] + fn worktree_binding_executes_in_worktree_but_persists_in_project() { + let project_root = PathBuf::from("/tmp/bitfun-project"); + let worktree_root = PathBuf::from("/tmp/bitfun-worktrees/wt-1"); + let execution_target = SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("wt-1".to_string()), + root_path: worktree_root.to_string_lossy().to_string(), + base_ref: Some("HEAD".to_string()), + base_commit: Some("0123456789abcdef".to_string()), + branch: None, + lifecycle: Some(WorktreeLifecycle::Managed), + }; + let binding = WorkspaceBinding::new(None, worktree_root.clone()) + .with_project_root_path(project_root.clone()) + .with_execution_target(Some(execution_target.clone())); + let runtime = WorkspaceRuntimeService::new(crate::infrastructure::get_path_manager_arc()); + + assert_eq!(binding.root_path(), worktree_root); + assert_eq!(binding.project_root_path(), project_root); + assert_eq!(binding.execution_target, Some(execution_target)); + assert_eq!( + binding.session_storage_dir(), + runtime + .context_for_local_workspace(&project_root) + .sessions_dir + ); + } } // Workspace-level I/O contracts are owned by bitfun-runtime-ports and the diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index 2d6b6f55ee..85fb1ea9ff 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -397,6 +397,11 @@ impl PathManager { self.bitfun_home_dir().join("projects") } + /// Default root for opt-in managed Git worktrees. + pub fn worktrees_root(&self) -> PathBuf { + self.bitfun_home_dir().join("worktrees") + } + /// Get the runtime root for a workspace: ~/.bitfun/projects// pub fn project_runtime_root(&self, workspace_path: &Path) -> PathBuf { self.projects_root() diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 6d5d4b170b..2fe5789428 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -4,6 +4,7 @@ use crate::util::errors::*; use async_trait::async_trait; +use bitfun_core_types::WorktreeSettings; use bitfun_runtime_ports::{PermissionRule, ToolPermissionConfig}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -139,6 +140,9 @@ pub struct AppConfig { /// Native agent lifecycle hooks (Codex-compatible hooks.json). #[serde(default)] pub hooks: AgentHooksConfig, + /// Defaults for opt-in managed Git worktrees. + #[serde(default)] + pub worktrees: WorktreeSettings, } /// Enablement gates for native agent hooks. @@ -1695,6 +1699,7 @@ impl Default for AppConfig { user_skill_groups: UserSkillGroupsConfig::default(), close_button_behavior: default_close_button_behavior(), hooks: AgentHooksConfig::default(), + worktrees: WorktreeSettings::default(), } } } diff --git a/src/crates/assembly/core/src/service/cron/schedule.rs b/src/crates/assembly/core/src/service/cron/schedule.rs index d61571cd42..4539f3a429 100644 --- a/src/crates/assembly/core/src/service/cron/schedule.rs +++ b/src/crates/assembly/core/src/service/cron/schedule.rs @@ -179,6 +179,8 @@ mod tests { workspace: CronWorkspaceRef { workspace_id: None, workspace_path: "E:/workspace".to_string(), + project_workspace_path: None, + execution_target: None, remote_connection_id: None, remote_ssh_host: None, }, diff --git a/src/crates/assembly/core/src/service/cron/service.rs b/src/crates/assembly/core/src/service/cron/service.rs index 2f75574f54..221d3aeb15 100644 --- a/src/crates/assembly/core/src/service/cron/service.rs +++ b/src/crates/assembly/core/src/service/cron/service.rs @@ -613,6 +613,8 @@ impl CronService { launch.agent_type.clone(), SessionConfig { workspace_path: Some(workspace.workspace_path.clone()), + project_workspace_path: workspace.project_workspace_path.clone(), + execution_target: workspace.execution_target.clone(), workspace_id: workspace.workspace_id.clone(), remote_connection_id: workspace.remote_connection_id.clone(), remote_ssh_host: workspace.remote_ssh_host.clone(), @@ -801,6 +803,11 @@ fn materialize_workspace_ref(workspace: CronWorkspaceRef) -> CronWorkspaceRef { .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()), workspace_path: normalize_workspace_path_for_matching(&workspace.workspace_path), + project_workspace_path: workspace + .project_workspace_path + .map(|value| normalize_workspace_path_for_matching(&value)) + .filter(|value| !value.is_empty()), + execution_target: workspace.execution_target, remote_connection_id: workspace .remote_connection_id .map(|value| value.trim().to_string()) @@ -816,6 +823,10 @@ fn workspace_ref_from_binding(binding: &WorkspaceBinding) -> CronWorkspaceRef { CronWorkspaceRef { workspace_id: binding.workspace_id.clone(), workspace_path: normalize_workspace_path_for_matching(&binding.root_path_string()), + project_workspace_path: Some(normalize_workspace_path_for_matching( + &binding.project_root_path_string(), + )), + execution_target: binding.execution_target.clone(), remote_connection_id: binding.connection_id().map(ToOwned::to_owned), remote_ssh_host: if binding.is_remote() { Some(binding.session_identity.hostname.clone()).filter(|value| !value.trim().is_empty()) @@ -1034,6 +1045,8 @@ mod tests { workspace: CronWorkspaceRef { workspace_id: None, workspace_path: "E:/workspace".to_string(), + project_workspace_path: None, + execution_target: None, remote_connection_id: None, remote_ssh_host: None, }, @@ -1062,6 +1075,8 @@ mod tests { let workspace = materialize_workspace_ref(CronWorkspaceRef { workspace_id: None, workspace_path: r"c:\Users\wsp\.bitfun\personal_assistant\workspace\".to_string(), + project_workspace_path: None, + execution_target: None, remote_connection_id: None, remote_ssh_host: None, }); @@ -1077,6 +1092,8 @@ mod tests { let workspace = CronWorkspaceRef { workspace_id: Some("local_workspace".to_string()), workspace_path: r"C:\Users\wsp\.bitfun\personal_assistant\workspace".to_string(), + project_workspace_path: None, + execution_target: None, remote_connection_id: None, remote_ssh_host: None, }; @@ -1094,6 +1111,8 @@ mod tests { let workspace = CronWorkspaceRef { workspace_id: None, workspace_path: "/home/wsp/projects/test/".to_string(), + project_workspace_path: None, + execution_target: None, remote_connection_id: Some("ssh-1".to_string()), remote_ssh_host: Some("host-1".to_string()), }; diff --git a/src/crates/assembly/core/src/service/cron/store.rs b/src/crates/assembly/core/src/service/cron/store.rs index aacf906064..30cb59d495 100644 --- a/src/crates/assembly/core/src/service/cron/store.rs +++ b/src/crates/assembly/core/src/service/cron/store.rs @@ -182,6 +182,8 @@ fn migrate_legacy_job(legacy: LegacyCronJobV1) -> CronJob { workspace: CronWorkspaceRef { workspace_id: None, workspace_path: legacy.workspace_path, + project_workspace_path: None, + execution_target: None, remote_connection_id: None, remote_ssh_host: None, }, diff --git a/src/crates/assembly/core/src/service/cron/types.rs b/src/crates/assembly/core/src/service/cron/types.rs index ecaeb258c5..e3b9f2c78c 100644 --- a/src/crates/assembly/core/src/service/cron/types.rs +++ b/src/crates/assembly/core/src/service/cron/types.rs @@ -4,6 +4,7 @@ use bitfun_agent_runtime::scheduled_job::DEFAULT_SCHEDULED_JOB_RETRY_DELAY_MS; pub use bitfun_agent_runtime::scheduled_job::{ ScheduledJobRunStatus as CronJobRunStatus, ScheduledJobRuntimeState as CronJobState, }; +use bitfun_core_types::SessionExecutionTarget; use serde::{Deserialize, Serialize}; pub const CRON_JOBS_VERSION: u32 = 2; @@ -78,6 +79,10 @@ pub struct CronWorkspaceRef { pub workspace_id: Option, pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_ssh_host: Option, diff --git a/src/crates/assembly/core/src/service/mod.rs b/src/crates/assembly/core/src/service/mod.rs index 1b809e5d51..b56f1bab93 100644 --- a/src/crates/assembly/core/src/service/mod.rs +++ b/src/crates/assembly/core/src/service/mod.rs @@ -38,6 +38,8 @@ pub mod snapshot; // Snapshot-based change tracking pub mod token_usage; // Token usage tracking pub mod workspace; // Workspace management // Diff calculation and merge service pub mod workspace_runtime; // Workspace runtime layout / migration / initialization +#[cfg(feature = "product-full")] +pub mod worktree; // Managed Git worktree lifecycle and session bindings // Terminal is implemented in the workspace-level `terminal-core` crate. // This re-export preserves the legacy `bitfun_core::service::terminal` path. @@ -113,3 +115,5 @@ pub use workspace_runtime::{ RuntimeMigrationRecord, WorkspaceRuntimeContext, WorkspaceRuntimeEnsureResult, WorkspaceRuntimeService, WorkspaceRuntimeTarget, }; +#[cfg(feature = "product-full")] +pub use worktree::WorktreeService; diff --git a/src/crates/assembly/core/src/service/workspace_runtime/service.rs b/src/crates/assembly/core/src/service/workspace_runtime/service.rs index c602f0cc16..d4c15e8314 100644 --- a/src/crates/assembly/core/src/service/workspace_runtime/service.rs +++ b/src/crates/assembly/core/src/service/workspace_runtime/service.rs @@ -147,7 +147,7 @@ impl WorkspaceRuntimeService { ) .await } else { - self.ensure_local_workspace_runtime(workspace.root_path()) + self.ensure_local_workspace_runtime(workspace.project_root_path()) .await } } diff --git a/src/crates/assembly/core/src/service/worktree/mod.rs b/src/crates/assembly/core/src/service/worktree/mod.rs new file mode 100644 index 0000000000..7bbf19ab34 --- /dev/null +++ b/src/crates/assembly/core/src/service/worktree/mod.rs @@ -0,0 +1,1593 @@ +//! Product-owned managed Git worktree lifecycle. +//! +//! Concrete Git and filesystem operations remain in `services-integrations`. +//! This module owns registry reconciliation, idempotency, lifecycle policy, +//! session association, and safe-removal decisions. + +use crate::infrastructure::events::{emit_global_event, BackendEvent}; +use crate::infrastructure::{get_path_manager_arc, PathManager}; +use crate::service::config::GlobalConfigManager; +use crate::service::git::{GitError, GitService, GitWorktreeInfo}; +use crate::service::workspace::{ + get_global_workspace_service, WorkspaceActivityMode, WorkspaceCreateOptions, +}; +use crate::service::workspace_runtime::get_workspace_runtime_service_arc; +use bitfun_core_types::{ + SessionExecutionTarget, SessionExecutionTargetKind, WorktreeError, WorktreeErrorCode, + WorktreeLifecycle, WorktreeSessionSummary, WorktreeSettings, WorktreeSummary, +}; +use bitfun_services_core::json_store::JsonFileStore; +use bitfun_services_core::session::{SessionMetadata, SessionMetadataStore, SessionStatus}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use tokio::sync::Mutex as AsyncMutex; +use uuid::Uuid; + +const WORKTREE_REGISTRY_VERSION: u32 = 1; +const REGISTRY_FILE_NAME: &str = "worktrees.json"; + +static REPOSITORY_LOCKS: OnceLock>>>> = OnceLock::new(); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeListRequest { + pub project_workspace_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeCreateRequest { + pub request_id: String, + pub project_workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_ref: Option, + #[serde(default)] + pub copy_local_changes: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeCreateResult { + pub worktree: WorktreeSummary, + pub execution_target: SessionExecutionTarget, + pub created: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeCreateBranchRequest { + pub request_id: String, + pub project_workspace_path: String, + pub worktree_id: String, + pub branch: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreePromoteRequest { + pub request_id: String, + pub project_workspace_path: String, + pub worktree_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeRemoveRequest { + pub request_id: String, + pub project_workspace_path: String, + pub worktree_id: String, + #[serde(default)] + pub force: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeRecreateRequest { + pub request_id: String, + pub project_workspace_path: String, + pub worktree_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeMutationResult { + pub worktree: WorktreeSummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeRemoveResult { + pub worktree_id: String, + pub removed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorktreeRegistry { + version: u32, + project_workspace_path: String, + #[serde(default)] + worktrees: Vec, + #[serde(default)] + receipts: HashMap, +} + +impl WorktreeRegistry { + fn new(project_workspace_path: &Path) -> Self { + Self { + version: WORKTREE_REGISTRY_VERSION, + project_workspace_path: path_string(project_workspace_path), + worktrees: Vec::new(), + receipts: HashMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RegisteredWorktree { + worktree_id: String, + path: String, + base_ref: Option, + base_commit: String, + branch: Option, + lifecycle: WorktreeLifecycle, + created_at_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +enum WorktreeOperationReceipt { + Create { + worktree_id: String, + source_workspace_path: String, + base_ref: String, + copy_local_changes: bool, + }, + CreateBranch { + worktree_id: String, + branch: String, + }, + Promote { + worktree_id: String, + }, + Remove { + worktree_id: String, + #[serde(default)] + force: bool, + }, + Recreate { + worktree_id: String, + }, +} + +impl WorktreeOperationReceipt { + fn worktree_id(&self) -> &str { + match self { + Self::Create { worktree_id, .. } + | Self::CreateBranch { worktree_id, .. } + | Self::Promote { worktree_id } + | Self::Remove { worktree_id, .. } + | Self::Recreate { worktree_id } => worktree_id, + } + } +} + +struct RepositoryContext { + project_workspace_path: PathBuf, + common_git_dir: PathBuf, + registry_path: PathBuf, + settings: WorktreeSettings, +} + +pub struct WorktreeService; + +impl WorktreeService { + /// Stable session identity for an idempotent worktree-session request. + pub fn session_id_for_request(request_id: &str) -> Result { + validate_request_id(request_id)?; + Ok(format!("worktree-session-{}", short_hash(request_id))) + } + + /// Compensates a just-created worktree when atomic session creation fails. + /// This is intentionally not exposed through Tauri or Agent tools. + pub async fn rollback_created( + project_workspace_path: &str, + worktree_id: &str, + ) -> Result<(), WorktreeError> { + let context = Self::repository_context(Path::new(project_workspace_path)).await?; + let lock = repository_lock(&context.common_git_dir); + let _guard = lock.lock().await; + let _process_guard = Self::acquire_repository_process_lock(&context).await?; + let mut registry = Self::load_registry(&context).await?; + let record = registry + .worktrees + .iter() + .find(|record| record.worktree_id == worktree_id) + .cloned() + .ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + "Managed worktree was not found during rollback", + ) + })?; + if Path::new(&record.path).exists() { + GitService::remove_worktree(&context.project_workspace_path, &record.path, true) + .await + .map_err(map_git_error)?; + } else { + GitService::prune_worktrees(&context.project_workspace_path) + .await + .map_err(map_git_error)?; + } + let mut cleanup_issues = Vec::new(); + if let Some(workspace_service) = get_global_workspace_service() { + if let Some(workspace) = workspace_service + .get_workspace_by_path(Path::new(&record.path)) + .await + { + if let Err(remove_error) = workspace_service.remove_workspace(&workspace.id).await { + cleanup_issues.push(format!( + "workspace registration could not be removed: {remove_error}" + )); + } + } + } + registry + .worktrees + .retain(|registered| registered.worktree_id != worktree_id); + registry + .receipts + .retain(|_, receipt| receipt.worktree_id() != worktree_id); + if let Err(registry_error) = Self::save_registry(&context, ®istry).await { + cleanup_issues.push(format!("registry could not be updated: {registry_error}")); + } + notify_changed(&context.project_workspace_path).await; + if cleanup_issues.is_empty() { + Ok(()) + } else { + Err(WorktreeError { + code: WorktreeErrorCode::RollbackIncomplete, + message: cleanup_issues.join("; "), + recovery_path: Some(record.path), + }) + } + } + + pub async fn list(request: WorktreeListRequest) -> Result, WorktreeError> { + let context = Self::repository_context(Path::new(&request.project_workspace_path)).await?; + let lock = repository_lock(&context.common_git_dir); + let _guard = lock.lock().await; + let _process_guard = Self::acquire_repository_process_lock(&context).await?; + let mut registry = Self::load_registry(&context).await?; + let (summaries, changed) = Self::reconcile(&context, &mut registry).await?; + if changed { + Self::save_registry(&context, ®istry).await?; + } + Ok(summaries) + } + + pub async fn create( + request: WorktreeCreateRequest, + ) -> Result { + validate_request_id(&request.request_id)?; + let context = Self::repository_context(Path::new(&request.project_workspace_path)).await?; + let lock = repository_lock(&context.common_git_dir); + let _guard = lock.lock().await; + let _process_guard = Self::acquire_repository_process_lock(&context).await?; + let mut registry = Self::load_registry(&context).await?; + let source_path = request + .source_workspace_path + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| context.project_workspace_path.clone()); + let source_workspace_path = normalized_lookup_path(&source_path); + let base_ref = request.base_ref.as_deref().unwrap_or("HEAD").trim(); + + if let Some(receipt) = registry.receipts.get(&request.request_id).cloned() { + return match receipt { + WorktreeOperationReceipt::Create { + worktree_id, + source_workspace_path: receipt_source, + base_ref: receipt_base_ref, + copy_local_changes, + } if receipt_source == source_workspace_path + && receipt_base_ref == base_ref + && copy_local_changes == request.copy_local_changes => + { + Self::create_result_for_id(&context, &mut registry, &worktree_id, false).await + } + _ => Err(error( + WorktreeErrorCode::RequestConflict, + "The requestId was already used with different worktree creation parameters", + )), + }; + } + + let source_repository = GitService::resolve_worktree_repository(&source_path) + .await + .map_err(map_git_error)?; + if source_repository.common_git_dir != context.common_git_dir { + return Err(error( + WorktreeErrorCode::InvalidPath, + "The source workspace does not belong to the selected project repository", + )); + } + + let base_commit = GitService::resolve_revision(&source_path, base_ref) + .await + .map_err(|git_error| map_base_ref_error(git_error, base_ref))?; + if request.copy_local_changes { + let source_head = GitService::resolve_revision(&source_path, "HEAD") + .await + .map_err(map_git_error)?; + if source_head != base_commit { + return Err(error( + WorktreeErrorCode::CopyConflict, + "Local changes can only be copied when the selected base resolves to source HEAD", + )); + } + } + + let worktree_id = Uuid::new_v4().simple().to_string(); + let repository_id = repository_id(&context.common_git_dir); + let target_path = + managed_target_path(&context.settings, &repository_id, &worktree_id).await?; + + GitService::add_detached_worktree( + &context.project_workspace_path, + &target_path, + &base_commit, + ) + .await + .map_err(map_git_error)?; + + if request.copy_local_changes { + if let Err(copy_error) = + GitService::copy_local_changes(&source_path, &target_path).await + { + return Err(Self::rollback_new_worktree( + &context, + &target_path, + map_copy_error(copy_error), + ) + .await); + } + } + + let tracked_workspace_id = if let Some(workspace_service) = get_global_workspace_service() { + match workspace_service + .track_workspace_activity( + target_path.clone(), + WorkspaceCreateOptions::default(), + WorkspaceActivityMode::RefreshMetadata, + ) + .await + { + Ok(workspace) => Some(workspace.id), + Err(track_error) => { + return Err(Self::rollback_new_worktree( + &context, + &target_path, + error( + WorktreeErrorCode::IoFailed, + format!("Failed to register the worktree workspace: {track_error}"), + ), + ) + .await); + } + } + } else { + None + }; + + registry.worktrees.push(RegisteredWorktree { + worktree_id: worktree_id.clone(), + path: path_string(&target_path), + base_ref: Some(base_ref.to_string()), + base_commit: base_commit.clone(), + branch: None, + lifecycle: WorktreeLifecycle::Managed, + created_at_ms: current_unix_ms(), + }); + registry.receipts.insert( + request.request_id, + WorktreeOperationReceipt::Create { + worktree_id: worktree_id.clone(), + source_workspace_path, + base_ref: base_ref.to_string(), + copy_local_changes: request.copy_local_changes, + }, + ); + if let Err(registry_error) = Self::save_registry(&context, ®istry).await { + return Err(Self::rollback_new_worktree_with_workspace( + &context, + &target_path, + tracked_workspace_id.as_deref(), + registry_error, + ) + .await); + } + + let result = + Self::create_result_for_id(&context, &mut registry, &worktree_id, true).await?; + notify_changed(&context.project_workspace_path).await; + Ok(result) + } + + pub async fn create_branch( + request: WorktreeCreateBranchRequest, + ) -> Result { + validate_request_id(&request.request_id)?; + let branch = request.branch.trim().to_string(); + if branch.is_empty() { + return Err(error( + WorktreeErrorCode::InvalidPath, + "Branch name cannot be empty", + )); + } + let context = Self::repository_context(Path::new(&request.project_workspace_path)).await?; + let lock = repository_lock(&context.common_git_dir); + let _guard = lock.lock().await; + let _process_guard = Self::acquire_repository_process_lock(&context).await?; + let mut registry = Self::load_registry(&context).await?; + + if let Some(receipt) = registry.receipts.get(&request.request_id).cloned() { + return match receipt { + WorktreeOperationReceipt::CreateBranch { + worktree_id, + branch: receipt_branch, + } if worktree_id == request.worktree_id && receipt_branch == branch => { + Self::mutation_result_for_id(&context, &mut registry, &worktree_id).await + } + _ => Err(error( + WorktreeErrorCode::RequestConflict, + "The requestId was already used with different branch parameters", + )), + }; + } + + let record = registry + .worktrees + .iter_mut() + .find(|record| record.worktree_id == request.worktree_id) + .ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + "Managed worktree was not found", + ) + })?; + if !Path::new(&record.path).is_dir() { + return Err(error( + WorktreeErrorCode::WorktreeNotFound, + "Worktree directory is missing; recreate it before creating a branch", + )); + } + let info = GitService::create_worktree_branch(&record.path, &branch) + .await + .map_err(map_branch_error)?; + record.branch = info.branch; + registry.receipts.insert( + request.request_id, + WorktreeOperationReceipt::CreateBranch { + worktree_id: request.worktree_id.clone(), + branch, + }, + ); + Self::save_registry(&context, ®istry).await?; + let result = + Self::mutation_result_for_id(&context, &mut registry, &request.worktree_id).await?; + notify_changed(&context.project_workspace_path).await; + Ok(result) + } + + pub async fn promote( + request: WorktreePromoteRequest, + ) -> Result { + validate_request_id(&request.request_id)?; + let context = Self::repository_context(Path::new(&request.project_workspace_path)).await?; + let lock = repository_lock(&context.common_git_dir); + let _guard = lock.lock().await; + let _process_guard = Self::acquire_repository_process_lock(&context).await?; + let mut registry = Self::load_registry(&context).await?; + if let Some(receipt) = registry.receipts.get(&request.request_id).cloned() { + return match receipt { + WorktreeOperationReceipt::Promote { worktree_id } + if worktree_id == request.worktree_id => + { + Self::mutation_result_for_id(&context, &mut registry, &worktree_id).await + } + _ => Err(error( + WorktreeErrorCode::RequestConflict, + "The requestId was already used with different promote parameters", + )), + }; + } + let record = registry + .worktrees + .iter_mut() + .find(|record| record.worktree_id == request.worktree_id) + .ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + "Managed worktree was not found", + ) + })?; + if record.lifecycle != WorktreeLifecycle::Managed { + return Err(error( + WorktreeErrorCode::InvalidPath, + "Only managed worktrees can be kept as permanent worktrees", + )); + } + record.lifecycle = WorktreeLifecycle::Permanent; + registry.receipts.insert( + request.request_id, + WorktreeOperationReceipt::Promote { + worktree_id: request.worktree_id.clone(), + }, + ); + Self::save_registry(&context, ®istry).await?; + let result = + Self::mutation_result_for_id(&context, &mut registry, &request.worktree_id).await?; + notify_changed(&context.project_workspace_path).await; + Ok(result) + } + + pub async fn remove( + request: WorktreeRemoveRequest, + ) -> Result { + validate_request_id(&request.request_id)?; + let context = Self::repository_context(Path::new(&request.project_workspace_path)).await?; + let lock = repository_lock(&context.common_git_dir); + let _guard = lock.lock().await; + let _process_guard = Self::acquire_repository_process_lock(&context).await?; + let mut registry = Self::load_registry(&context).await?; + if let Some(receipt) = registry.receipts.get(&request.request_id) { + return match receipt { + WorktreeOperationReceipt::Remove { worktree_id, force } + if worktree_id == &request.worktree_id && *force == request.force => + { + Ok(WorktreeRemoveResult { + worktree_id: worktree_id.clone(), + removed: true, + }) + } + _ => Err(error( + WorktreeErrorCode::RequestConflict, + "The requestId was already used with different remove parameters", + )), + }; + } + + let (summaries, _) = Self::reconcile(&context, &mut registry).await?; + let summary = summaries + .iter() + .find(|summary| summary.worktree_id == request.worktree_id) + .ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + "Managed worktree was not found", + ) + })?; + validate_removal(summary, request.force)?; + + GitService::remove_worktree( + &context.project_workspace_path, + &summary.path, + request.force, + ) + .await + .map_err(map_git_error)?; + let mut cleanup_issues = Vec::new(); + if let Some(workspace_service) = get_global_workspace_service() { + if let Some(workspace) = workspace_service + .get_workspace_by_path(Path::new(&summary.path)) + .await + { + if let Err(remove_error) = workspace_service.remove_workspace(&workspace.id).await { + cleanup_issues.push(format!( + "workspace registration could not be removed: {remove_error}" + )); + } + } + } + registry + .worktrees + .retain(|record| record.worktree_id != request.worktree_id); + registry.receipts.insert( + request.request_id, + WorktreeOperationReceipt::Remove { + worktree_id: request.worktree_id.clone(), + force: request.force, + }, + ); + if let Err(registry_error) = Self::save_registry(&context, ®istry).await { + cleanup_issues.push(format!("registry could not be updated: {registry_error}")); + } + notify_changed(&context.project_workspace_path).await; + if !cleanup_issues.is_empty() { + return Err(WorktreeError { + code: WorktreeErrorCode::RollbackIncomplete, + message: format!( + "Worktree was removed, but cleanup did not complete: {}", + cleanup_issues.join("; ") + ), + recovery_path: Some(summary.path.clone()), + }); + } + Ok(WorktreeRemoveResult { + worktree_id: request.worktree_id, + removed: true, + }) + } + + pub async fn recreate( + request: WorktreeRecreateRequest, + ) -> Result { + validate_request_id(&request.request_id)?; + let context = Self::repository_context(Path::new(&request.project_workspace_path)).await?; + let lock = repository_lock(&context.common_git_dir); + let _guard = lock.lock().await; + let _process_guard = Self::acquire_repository_process_lock(&context).await?; + let mut registry = Self::load_registry(&context).await?; + if let Some(receipt) = registry.receipts.get(&request.request_id).cloned() { + return match receipt { + WorktreeOperationReceipt::Recreate { worktree_id } + if worktree_id == request.worktree_id => + { + Self::mutation_result_for_id(&context, &mut registry, &worktree_id).await + } + _ => Err(error( + WorktreeErrorCode::RequestConflict, + "The requestId was already used with different recreate parameters", + )), + }; + } + + let record = registry + .worktrees + .iter() + .find(|record| record.worktree_id == request.worktree_id) + .cloned() + .ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + "Managed worktree was not found", + ) + })?; + if Path::new(&record.path).exists() { + return Err(error( + WorktreeErrorCode::InvalidPath, + "Worktree directory already exists", + )); + } + GitService::prune_worktrees(&context.project_workspace_path) + .await + .map_err(map_git_error)?; + GitService::add_detached_worktree( + &context.project_workspace_path, + &record.path, + &record.base_commit, + ) + .await + .map_err(map_git_error)?; + if let Some(branch) = record.branch.as_deref() { + if let Err(branch_error) = + GitService::attach_worktree_branch(&record.path, branch).await + { + return Err(Self::rollback_new_worktree( + &context, + Path::new(&record.path), + map_branch_error(branch_error), + ) + .await); + } + } + registry.receipts.insert( + request.request_id, + WorktreeOperationReceipt::Recreate { + worktree_id: request.worktree_id.clone(), + }, + ); + Self::save_registry(&context, ®istry).await?; + let result = + Self::mutation_result_for_id(&context, &mut registry, &request.worktree_id).await?; + notify_changed(&context.project_workspace_path).await; + Ok(result) + } + + async fn repository_context(project_path: &Path) -> Result { + if !project_path.is_dir() { + return Err(error( + WorktreeErrorCode::InvalidPath, + "Project workspace path does not exist", + )); + } + let repository_info = GitService::resolve_worktree_repository(project_path) + .await + .map_err(map_git_error)?; + let worktrees = GitService::list_worktrees(project_path) + .await + .map_err(map_git_error)?; + let project_workspace_path = worktrees + .iter() + .find(|worktree| worktree.is_main) + .map(|worktree| PathBuf::from(&worktree.path)) + .unwrap_or_else(|| repository_info.query_path.clone()); + let project_workspace_path = + std::fs::canonicalize(&project_workspace_path).unwrap_or(project_workspace_path); + let runtime = get_workspace_runtime_service_arc() + .ensure_local_workspace_runtime(&project_workspace_path) + .await + .map_err(|runtime_error| { + error( + WorktreeErrorCode::IoFailed, + format!("Failed to initialize project runtime: {runtime_error}"), + ) + })?; + Ok(RepositoryContext { + project_workspace_path, + common_git_dir: repository_info.common_git_dir, + registry_path: runtime.context.config_dir.join(REGISTRY_FILE_NAME), + settings: load_settings().await, + }) + } + + async fn load_registry(context: &RepositoryContext) -> Result { + let registry = JsonFileStore + .read_optional(&context.registry_path) + .await + .map_err(|store_error| { + error( + WorktreeErrorCode::IoFailed, + format!("Failed to read worktree registry: {store_error}"), + ) + })? + .unwrap_or_else(|| WorktreeRegistry::new(&context.project_workspace_path)); + if registry.version != WORKTREE_REGISTRY_VERSION { + return Err(error( + WorktreeErrorCode::IoFailed, + format!( + "Unsupported worktree registry version: {}", + registry.version + ), + )); + } + Ok(registry) + } + + async fn acquire_repository_process_lock( + context: &RepositoryContext, + ) -> Result { + JsonFileStore + .acquire_cross_process_lock(&context.registry_path) + .await + .map_err(|lock_error| { + error( + WorktreeErrorCode::IoFailed, + format!("Failed to lock the worktree registry: {lock_error}"), + ) + }) + } + + async fn save_registry( + context: &RepositoryContext, + registry: &WorktreeRegistry, + ) -> Result<(), WorktreeError> { + JsonFileStore + .write_atomic_strict(&context.registry_path, registry) + .await + .map_err(|store_error| { + error( + WorktreeErrorCode::IoFailed, + format!("Failed to persist worktree registry: {store_error}"), + ) + }) + } + + async fn reconcile( + context: &RepositoryContext, + registry: &mut WorktreeRegistry, + ) -> Result<(Vec, bool), WorktreeError> { + let git_worktrees = GitService::list_worktrees(&context.project_workspace_path) + .await + .map_err(map_git_error)?; + let sessions = load_project_sessions(&context.project_workspace_path).await?; + let registered_by_path = registry + .worktrees + .iter() + .map(|record| { + ( + normalized_lookup_path(Path::new(&record.path)), + record.clone(), + ) + }) + .collect::>(); + let mut seen_registered_ids = HashSet::new(); + let mut summaries = Vec::new(); + let mut changed = false; + + for git_worktree in git_worktrees { + let lookup_path = normalized_lookup_path(Path::new(&git_worktree.path)); + let missing = git_worktree.is_prunable || !Path::new(&git_worktree.path).is_dir(); + let registered = registered_by_path.get(&lookup_path); + if let Some(record) = registered { + seen_registered_ids.insert(record.worktree_id.clone()); + } + let worktree_id = if git_worktree.is_main { + "main".to_string() + } else if let Some(record) = registered { + record.worktree_id.clone() + } else { + let worktree_id = format!( + "external-{}", + short_hash(&format!( + "{}:{lookup_path}", + path_string(&context.common_git_dir) + )) + ); + registry.worktrees.push(RegisteredWorktree { + worktree_id: worktree_id.clone(), + path: git_worktree.path.clone(), + base_ref: git_worktree.branch.clone(), + base_commit: git_worktree.head.clone(), + branch: git_worktree.branch.clone(), + lifecycle: WorktreeLifecycle::External, + created_at_ms: current_unix_ms(), + }); + seen_registered_ids.insert(worktree_id.clone()); + changed = true; + worktree_id + }; + let lifecycle = registered + .map(|record| record.lifecycle) + .unwrap_or(WorktreeLifecycle::External); + summaries.push( + build_summary( + context, + &worktree_id, + lifecycle, + git_worktree, + missing, + &sessions, + ) + .await?, + ); + } + + for record in registry.worktrees.iter() { + if seen_registered_ids.contains(&record.worktree_id) { + continue; + } + let missing_info = GitWorktreeInfo { + path: record.path.clone(), + branch: record.branch.clone(), + head: record.base_commit.clone(), + is_main: false, + is_locked: false, + is_prunable: true, + }; + summaries.push( + build_summary( + context, + &record.worktree_id, + record.lifecycle, + missing_info, + true, + &sessions, + ) + .await?, + ); + } + + summaries.sort_by(|left, right| { + right + .is_main + .cmp(&left.is_main) + .then_with(|| left.path.cmp(&right.path)) + }); + if let Some(workspace_service) = get_global_workspace_service() { + for summary in &summaries { + if summary.is_main + || summary.missing + || summary.lifecycle == WorktreeLifecycle::External + || workspace_service + .get_workspace_by_path(Path::new(&summary.path)) + .await + .is_some() + { + continue; + } + workspace_service + .track_workspace_activity( + PathBuf::from(&summary.path), + WorkspaceCreateOptions::default(), + WorkspaceActivityMode::RefreshMetadata, + ) + .await + .map_err(|workspace_error| { + error( + WorktreeErrorCode::IoFailed, + format!( + "Failed to restore a managed worktree workspace registration: {workspace_error}" + ), + ) + })?; + } + } + Ok((summaries, changed)) + } + + async fn create_result_for_id( + context: &RepositoryContext, + registry: &mut WorktreeRegistry, + worktree_id: &str, + created: bool, + ) -> Result { + let record = registry + .worktrees + .iter() + .find(|record| record.worktree_id == worktree_id) + .cloned() + .ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + "Idempotent worktree result no longer exists", + ) + })?; + let (summaries, changed) = Self::reconcile(context, registry).await?; + if changed { + Self::save_registry(context, registry).await?; + } + let worktree = summaries + .into_iter() + .find(|summary| summary.worktree_id == worktree_id) + .ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + "Created worktree could not be reconciled", + ) + })?; + Ok(WorktreeCreateResult { + execution_target: SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some(record.worktree_id), + root_path: record.path, + base_ref: record.base_ref, + base_commit: Some(record.base_commit), + branch: record.branch, + lifecycle: Some(record.lifecycle), + }, + worktree, + created, + }) + } + + async fn mutation_result_for_id( + context: &RepositoryContext, + registry: &mut WorktreeRegistry, + worktree_id: &str, + ) -> Result { + let (summaries, changed) = Self::reconcile(context, registry).await?; + if changed { + Self::save_registry(context, registry).await?; + } + let worktree = summaries + .into_iter() + .find(|summary| summary.worktree_id == worktree_id) + .ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + "Worktree could not be reconciled", + ) + })?; + Ok(WorktreeMutationResult { worktree }) + } + + async fn rollback_new_worktree( + context: &RepositoryContext, + target_path: &Path, + original_error: WorktreeError, + ) -> WorktreeError { + match GitService::remove_worktree( + &context.project_workspace_path, + &path_string(target_path), + true, + ) + .await + { + Ok(_) => original_error, + Err(rollback_error) => WorktreeError { + code: WorktreeErrorCode::RollbackIncomplete, + message: format!( + "{}; automatic rollback also failed: {}", + original_error.message, rollback_error + ), + recovery_path: Some(path_string(target_path)), + }, + } + } + + async fn rollback_new_worktree_with_workspace( + context: &RepositoryContext, + target_path: &Path, + workspace_id: Option<&str>, + original_error: WorktreeError, + ) -> WorktreeError { + let mut rollback_issues = Vec::new(); + if let (Some(workspace_service), Some(workspace_id)) = + (get_global_workspace_service(), workspace_id) + { + if let Err(remove_error) = workspace_service.remove_workspace(workspace_id).await { + rollback_issues.push(format!( + "workspace registration could not be removed: {remove_error}" + )); + } + } + let git_rollback = + Self::rollback_new_worktree(context, target_path, original_error.clone()).await; + if git_rollback.code == WorktreeErrorCode::RollbackIncomplete { + rollback_issues.push(git_rollback.message); + } + if rollback_issues.is_empty() { + original_error + } else { + WorktreeError { + code: WorktreeErrorCode::RollbackIncomplete, + message: format!( + "{}; automatic rollback did not complete: {}", + original_error.message, + rollback_issues.join("; ") + ), + recovery_path: Some(path_string(target_path)), + } + } + } +} + +async fn notify_changed(project_workspace_path: &Path) { + if let Some(workspace_service) = get_global_workspace_service() { + workspace_service + .invalidate_worktree_topology(project_workspace_path) + .await; + } + if let Err(event_error) = emit_global_event(BackendEvent::Custom { + event_name: "worktree://changed".to_string(), + payload: serde_json::json!({ + "projectWorkspacePath": path_string(project_workspace_path), + }), + }) + .await + { + log::warn!("Failed to emit worktree change event: {event_error}"); + } +} + +async fn build_summary( + context: &RepositoryContext, + worktree_id: &str, + lifecycle: WorktreeLifecycle, + git_worktree: GitWorktreeInfo, + missing: bool, + sessions: &[SessionMetadata], +) -> Result { + let associated = sessions + .iter() + .filter(|metadata| { + metadata + .execution_target + .as_ref() + .and_then(|target| target.worktree_id.as_deref()) + == Some(worktree_id) + || metadata.workspace_path.as_deref() == Some(git_worktree.path.as_str()) + }) + .collect::>(); + let session_summaries = associated + .iter() + .map(|metadata| WorktreeSessionSummary { + session_id: metadata.session_id.clone(), + session_name: metadata.session_name.clone(), + status: session_status_name(&metadata.status).to_string(), + archived: matches!(metadata.status, SessionStatus::Archived), + }) + .collect::>(); + let running_session_count = associated + .iter() + .filter(|metadata| !matches!(metadata.status, SessionStatus::Archived)) + .count(); + let (dirty, unpublished) = if missing { + (false, false) + } else { + ( + GitService::worktree_is_dirty(&git_worktree.path) + .await + .map_err(map_git_error)?, + if git_worktree.branch.is_none() { + GitService::worktree_has_unpublished_commits(&git_worktree.path) + .await + .map_err(map_git_error)? + } else { + false + }, + ) + }; + Ok(WorktreeSummary { + worktree_id: worktree_id.to_string(), + project_workspace_path: path_string(&context.project_workspace_path), + path: git_worktree.path, + head: git_worktree.head, + branch: git_worktree.branch, + lifecycle, + is_main: git_worktree.is_main, + dirty, + locked: git_worktree.is_locked, + missing, + has_unpublished_commits: unpublished, + associated_session_count: session_summaries.len(), + running_session_count, + sessions: session_summaries, + }) +} + +async fn load_project_sessions( + project_workspace_path: &Path, +) -> Result, WorktreeError> { + let context = + get_workspace_runtime_service_arc().context_for_local_workspace(project_workspace_path); + SessionMetadataStore::new(context.sessions_dir) + .list_metadata_including_internal() + .await + .map_err(|session_error| { + error( + WorktreeErrorCode::IoFailed, + format!("Failed to read project sessions: {session_error}"), + ) + }) +} + +async fn load_settings() -> WorktreeSettings { + match GlobalConfigManager::get_service().await { + Ok(config_service) => config_service + .get_config::(Some("app.worktrees")) + .await + .unwrap_or_default(), + Err(_) => WorktreeSettings::default(), + } +} + +fn resolve_managed_root( + settings: &WorktreeSettings, + path_manager: &PathManager, +) -> Result { + let configured = settings.root_path.trim(); + if configured.is_empty() || configured == "~/.bitfun/worktrees" { + return Ok(path_manager.worktrees_root()); + } + if configured == "~" { + return dirs::home_dir().ok_or_else(|| { + error( + WorktreeErrorCode::InvalidPath, + "Unable to resolve the configured home directory", + ) + }); + } + if let Some(suffix) = configured.strip_prefix("~/") { + return dirs::home_dir() + .map(|home| home.join(suffix)) + .ok_or_else(|| { + error( + WorktreeErrorCode::InvalidPath, + "Unable to resolve the configured home directory", + ) + }); + } + let path = PathBuf::from(configured); + if !path.is_absolute() { + return Err(error( + WorktreeErrorCode::InvalidPath, + "Worktree root must be an absolute path or start with ~/", + )); + } + Ok(path) +} + +async fn managed_target_path( + settings: &WorktreeSettings, + repository_id: &str, + worktree_id: &str, +) -> Result { + let configured_root = resolve_managed_root(settings, get_path_manager_arc().as_ref())?; + tokio::fs::create_dir_all(&configured_root) + .await + .map_err(|io_error| { + error( + WorktreeErrorCode::IoFailed, + format!("Failed to create the managed worktree root: {io_error}"), + ) + })?; + let canonical_root = tokio::fs::canonicalize(&configured_root) + .await + .map_err(|io_error| { + error( + WorktreeErrorCode::IoFailed, + format!("Failed to resolve the managed worktree root: {io_error}"), + ) + })?; + let repository_root = canonical_root.join(repository_id); + match tokio::fs::symlink_metadata(&repository_root).await { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err(error( + WorktreeErrorCode::InvalidPath, + "Managed repository worktree root must be a regular directory", + )); + } + Ok(_) => {} + Err(io_error) if io_error.kind() == std::io::ErrorKind::NotFound => { + tokio::fs::create_dir(&repository_root) + .await + .map_err(|create_error| { + error( + WorktreeErrorCode::IoFailed, + format!("Failed to create the repository worktree root: {create_error}"), + ) + })?; + } + Err(io_error) => { + return Err(error( + WorktreeErrorCode::IoFailed, + format!("Failed to inspect the repository worktree root: {io_error}"), + )); + } + } + let canonical_repository_root = + tokio::fs::canonicalize(&repository_root) + .await + .map_err(|io_error| { + error( + WorktreeErrorCode::IoFailed, + format!("Failed to resolve the repository worktree root: {io_error}"), + ) + })?; + if !canonical_repository_root.starts_with(&canonical_root) { + return Err(error( + WorktreeErrorCode::InvalidPath, + "Managed repository worktree root escapes the configured root", + )); + } + let target_path = canonical_repository_root.join(worktree_id); + match tokio::fs::symlink_metadata(&target_path).await { + Ok(_) => Err(error( + WorktreeErrorCode::InvalidPath, + "Managed worktree target already exists", + )), + Err(io_error) if io_error.kind() == std::io::ErrorKind::NotFound => Ok(target_path), + Err(io_error) => Err(error( + WorktreeErrorCode::IoFailed, + format!("Failed to inspect the managed worktree target: {io_error}"), + )), + } +} + +fn repository_lock(common_git_dir: &Path) -> Arc> { + let locks = REPOSITORY_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut locks = locks.lock().expect("worktree repository lock map poisoned"); + locks + .entry(common_git_dir.to_path_buf()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() +} + +fn repository_id(common_git_dir: &Path) -> String { + short_hash(&path_string(common_git_dir)) +} + +fn short_hash(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + hex::encode(digest)[..16].to_string() +} + +fn normalized_lookup_path(path: &Path) -> String { + let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + path_string(&path) +} + +fn path_string(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn current_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn session_status_name(status: &SessionStatus) -> &'static str { + match status { + SessionStatus::Active => "active", + SessionStatus::Archived => "archived", + SessionStatus::Completed => "completed", + } +} + +fn validate_request_id(request_id: &str) -> Result<(), WorktreeError> { + if request_id.trim().is_empty() || request_id.len() > 200 { + return Err(error( + WorktreeErrorCode::RequestConflict, + "requestId must be between 1 and 200 bytes", + )); + } + Ok(()) +} + +fn error(code: WorktreeErrorCode, message: impl Into) -> WorktreeError { + WorktreeError { + code, + message: message.into(), + recovery_path: None, + } +} + +fn validate_removal(summary: &WorktreeSummary, force: bool) -> Result<(), WorktreeError> { + if summary.is_main { + return Err(error( + WorktreeErrorCode::InvalidPath, + "The main worktree cannot be removed", + )); + } + if summary.locked { + return Err(error( + WorktreeErrorCode::WorktreeLocked, + "The worktree is locked by Git", + )); + } + if summary.running_session_count > 0 { + return Err(error( + WorktreeErrorCode::WorktreeBusy, + "The worktree has active or unarchived sessions", + )); + } + if !force && summary.dirty { + return Err(error( + WorktreeErrorCode::DirtyWorktree, + "The worktree contains local changes", + )); + } + if !force && summary.has_unpublished_commits { + return Err(error( + WorktreeErrorCode::UnpublishedCommits, + "Detached HEAD contains commits that are not reachable from any ref", + )); + } + if summary.missing { + return Err(error( + WorktreeErrorCode::WorktreeNotFound, + "The worktree directory is missing; recreate it or remove the stale Git record manually", + )); + } + Ok(()) +} + +fn map_base_ref_error(git_error: GitError, base_ref: &str) -> WorktreeError { + let text = git_error.to_string(); + if text.to_ascii_lowercase().contains("unborn") + || text.contains("reference 'HEAD' not found") + || text.contains("needed a single revision") + { + error( + WorktreeErrorCode::UnbornRepo, + "The repository has no initial commit", + ) + } else { + error( + WorktreeErrorCode::InvalidBaseRef, + format!("Failed to resolve base ref '{base_ref}': {text}"), + ) + } +} + +fn map_branch_error(git_error: GitError) -> WorktreeError { + let text = git_error.to_string(); + if text.contains("already exists") { + error(WorktreeErrorCode::BranchExists, text) + } else { + map_git_error(git_error) + } +} + +fn map_copy_error(git_error: GitError) -> WorktreeError { + error(WorktreeErrorCode::CopyConflict, git_error.to_string()) +} + +fn map_git_error(git_error: GitError) -> WorktreeError { + match git_error { + GitError::RepositoryNotFound(message) => { + error(WorktreeErrorCode::NotGitRepository, message) + } + GitError::InvalidPath(message) => error(WorktreeErrorCode::InvalidPath, message), + GitError::IoError(io_error) => error(WorktreeErrorCode::IoFailed, io_error.to_string()), + other => { + let message = other.to_string(); + if message.to_ascii_lowercase().contains("unborn") || message.contains("initial commit") + { + error(WorktreeErrorCode::UnbornRepo, message) + } else { + error(WorktreeErrorCode::GitFailed, message) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + repository_id, resolve_managed_root, validate_removal, RegisteredWorktree, + RepositoryContext, WorktreeOperationReceipt, WorktreeRegistry, WorktreeService, + }; + use crate::infrastructure::PathManager; + use bitfun_core_types::{ + WorktreeErrorCode, WorktreeLifecycle, WorktreeSettings, WorktreeSummary, + }; + use std::path::Path; + + fn removable_summary() -> WorktreeSummary { + WorktreeSummary { + worktree_id: "wt-1".to_string(), + project_workspace_path: "/repo".to_string(), + path: "/worktrees/wt-1".to_string(), + head: "0123456789abcdef".to_string(), + branch: None, + lifecycle: WorktreeLifecycle::Managed, + is_main: false, + dirty: false, + locked: false, + missing: false, + has_unpublished_commits: false, + associated_session_count: 0, + running_session_count: 0, + sessions: Vec::new(), + } + } + + #[test] + fn repository_ids_are_stable_and_path_sensitive() { + assert_eq!(repository_id(Path::new("/repo/.git")).len(), 16); + assert_eq!( + repository_id(Path::new("/repo/.git")), + repository_id(Path::new("/repo/.git")) + ); + assert_ne!( + repository_id(Path::new("/repo/.git")), + repository_id(Path::new("/other/.git")) + ); + } + + #[test] + fn relative_custom_roots_are_rejected() { + let path_manager = PathManager::new().expect("path manager"); + let settings = WorktreeSettings { + root_path: "relative/worktrees".to_string(), + ..WorktreeSettings::default() + }; + assert!(resolve_managed_root(&settings, &path_manager).is_err()); + } + + #[test] + fn request_ids_map_to_stable_session_ids() { + let first = WorktreeService::session_id_for_request("request-123").unwrap(); + let replay = WorktreeService::session_id_for_request("request-123").unwrap(); + let other = WorktreeService::session_id_for_request("request-456").unwrap(); + assert_eq!(first, replay); + assert_ne!(first, other); + assert!(first.starts_with("worktree-session-")); + } + + #[test] + fn safe_removal_rejects_every_protected_state() { + let mut summary = removable_summary(); + summary.is_main = true; + assert_eq!( + validate_removal(&summary, false).unwrap_err().code, + WorktreeErrorCode::InvalidPath + ); + + let mut summary = removable_summary(); + summary.locked = true; + assert_eq!( + validate_removal(&summary, true).unwrap_err().code, + WorktreeErrorCode::WorktreeLocked + ); + + let mut summary = removable_summary(); + summary.running_session_count = 1; + assert_eq!( + validate_removal(&summary, true).unwrap_err().code, + WorktreeErrorCode::WorktreeBusy + ); + + let mut summary = removable_summary(); + summary.dirty = true; + assert_eq!( + validate_removal(&summary, false).unwrap_err().code, + WorktreeErrorCode::DirtyWorktree + ); + + let mut summary = removable_summary(); + summary.has_unpublished_commits = true; + assert_eq!( + validate_removal(&summary, false).unwrap_err().code, + WorktreeErrorCode::UnpublishedCommits + ); + + let mut summary = removable_summary(); + summary.missing = true; + assert_eq!( + validate_removal(&summary, true).unwrap_err().code, + WorktreeErrorCode::WorktreeNotFound + ); + } + + #[test] + fn force_only_bypasses_discardable_local_work() { + let mut summary = removable_summary(); + summary.dirty = true; + summary.has_unpublished_commits = true; + assert!(validate_removal(&summary, true).is_ok()); + } + + #[tokio::test] + async fn registry_round_trip_restores_binding_and_idempotency_receipt() { + let root = tempfile::tempdir().expect("temp root"); + let project = root.path().join("repo"); + let common_git_dir = project.join(".git"); + std::fs::create_dir_all(&common_git_dir).expect("repository dirs"); + let context = RepositoryContext { + project_workspace_path: project.clone(), + common_git_dir, + registry_path: root.path().join("runtime/worktrees.json"), + settings: WorktreeSettings::default(), + }; + let mut registry = WorktreeRegistry::new(&project); + registry.worktrees.push(RegisteredWorktree { + worktree_id: "wt-restored".to_string(), + path: "/managed/wt-restored".to_string(), + base_ref: Some("main".to_string()), + base_commit: "0123456789abcdef".to_string(), + branch: None, + lifecycle: WorktreeLifecycle::Managed, + created_at_ms: 123, + }); + registry.receipts.insert( + "request-restored".to_string(), + WorktreeOperationReceipt::Create { + worktree_id: "wt-restored".to_string(), + source_workspace_path: project.to_string_lossy().to_string(), + base_ref: "main".to_string(), + copy_local_changes: false, + }, + ); + + WorktreeService::save_registry(&context, ®istry) + .await + .expect("save registry"); + let restored = WorktreeService::load_registry(&context) + .await + .expect("load registry"); + + assert_eq!(restored.worktrees.len(), 1); + assert_eq!(restored.worktrees[0].worktree_id, "wt-restored"); + assert_eq!( + restored + .receipts + .get("request-restored") + .expect("receipt") + .worktree_id(), + "wt-restored" + ); + } +} diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index 2f18c10a76..af3088b90c 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -11,6 +11,7 @@ pub mod session_usage; pub mod speech; pub mod surface; pub mod tool_image_attachment; +pub mod worktree; pub use ai::{ AIConfig, ConnectionTestMessageCode, ConnectionTestResult, Message, ProxyConfig, ReasoningMode, @@ -28,3 +29,8 @@ pub use surface::{ RuntimeArtifactKind, RuntimeArtifactRef, SurfaceKind, ThreadEnvironment, ThreadEnvironmentKind, }; pub use tool_image_attachment::ToolImageAttachment; +pub use worktree::{ + SessionExecutionTarget, SessionExecutionTargetKind, SessionExecutionTargetRequest, + WorktreeDefaultTarget, WorktreeError, WorktreeErrorCode, WorktreeLifecycle, + WorktreeSessionSummary, WorktreeSettings, WorktreeSummary, +}; diff --git a/src/crates/contracts/core-types/src/worktree.rs b/src/crates/contracts/core-types/src/worktree.rs new file mode 100644 index 0000000000..48c5c394ad --- /dev/null +++ b/src/crates/contracts/core-types/src/worktree.rs @@ -0,0 +1,238 @@ +use serde::{Deserialize, Serialize}; + +/// User-facing choice for where a newly-created session executes. +/// +/// This is a request contract. Once resolved, sessions persist a +/// [`SessionExecutionTarget`] containing the immutable commit and concrete +/// execution root selected by the product layer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum SessionExecutionTargetRequest { + #[default] + Local, + NewManagedWorktree { + #[serde(default, skip_serializing_if = "Option::is_none")] + base_ref: Option, + #[serde(default)] + copy_local_changes: bool, + }, + ExistingWorktree { + worktree_id: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum SessionExecutionTargetKind { + #[default] + Local, + ManagedWorktree, + ExistingWorktree, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WorktreeLifecycle { + Managed, + Permanent, + External, +} + +/// Resolved and persisted execution location for a session. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionExecutionTarget { + pub kind: SessionExecutionTargetKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree_id: Option, + pub root_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lifecycle: Option, +} + +impl SessionExecutionTarget { + pub fn local(root_path: impl Into) -> Self { + Self { + kind: SessionExecutionTargetKind::Local, + worktree_id: None, + root_path: root_path.into(), + base_ref: None, + base_commit: None, + branch: None, + lifecycle: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum WorktreeDefaultTarget { + #[default] + Local, + ManagedWorktree, +} + +/// User-level defaults for new worktrees. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeSettings { + pub default_target: WorktreeDefaultTarget, + pub root_path: String, + pub branch_prefix: String, + pub copy_local_changes: bool, +} + +impl Default for WorktreeSettings { + fn default() -> Self { + Self { + default_target: WorktreeDefaultTarget::Local, + root_path: "~/.bitfun/worktrees".to_string(), + branch_prefix: "bitfun/".to_string(), + copy_local_changes: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeSessionSummary { + pub session_id: String, + pub session_name: String, + pub status: String, + #[serde(default)] + pub archived: bool, +} + +/// Reconciled worktree state shown to UI and tools. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeSummary { + pub worktree_id: String, + pub project_workspace_path: String, + pub path: String, + pub head: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + pub lifecycle: WorktreeLifecycle, + pub is_main: bool, + pub dirty: bool, + pub locked: bool, + pub missing: bool, + pub has_unpublished_commits: bool, + #[serde(default)] + pub associated_session_count: usize, + #[serde(default)] + pub running_session_count: usize, + #[serde(default)] + pub sessions: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorktreeErrorCode { + RemoteUnsupported, + NotGitRepository, + UnbornRepo, + InvalidBaseRef, + WorktreeNotFound, + WorktreeBusy, + WorktreeLocked, + DirtyWorktree, + UnpublishedCommits, + CopyConflict, + InvalidPath, + BranchExists, + RequestConflict, + RollbackIncomplete, + GitFailed, + IoFailed, +} + +impl WorktreeErrorCode { + pub fn as_str(self) -> &'static str { + match self { + Self::RemoteUnsupported => "remote_unsupported", + Self::NotGitRepository => "not_git_repository", + Self::UnbornRepo => "unborn_repo", + Self::InvalidBaseRef => "invalid_base_ref", + Self::WorktreeNotFound => "worktree_not_found", + Self::WorktreeBusy => "worktree_busy", + Self::WorktreeLocked => "worktree_locked", + Self::DirtyWorktree => "dirty_worktree", + Self::UnpublishedCommits => "unpublished_commits", + Self::CopyConflict => "copy_conflict", + Self::InvalidPath => "invalid_path", + Self::BranchExists => "branch_exists", + Self::RequestConflict => "request_conflict", + Self::RollbackIncomplete => "rollback_incomplete", + Self::GitFailed => "git_failed", + Self::IoFailed => "io_failed", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeError { + pub code: WorktreeErrorCode, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recovery_path: Option, +} + +impl std::fmt::Display for WorktreeError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}: {}", self.code.as_str(), self.message) + } +} + +impl std::error::Error for WorktreeError {} + +#[cfg(test)] +mod tests { + use super::{ + SessionExecutionTargetRequest, WorktreeError, WorktreeErrorCode, WorktreeSettings, + }; + + #[test] + fn execution_target_request_uses_stable_camel_case_tags() { + let value = serde_json::to_value(SessionExecutionTargetRequest::NewManagedWorktree { + base_ref: Some("main".to_string()), + copy_local_changes: true, + }) + .expect("request should serialize"); + + assert_eq!(value["kind"], "newManagedWorktree"); + assert_eq!(value["baseRef"], "main"); + assert_eq!(value["copyLocalChanges"], true); + } + + #[test] + fn worktree_defaults_are_safe_and_opt_in() { + let defaults = WorktreeSettings::default(); + assert_eq!(defaults.root_path, "~/.bitfun/worktrees"); + assert_eq!(defaults.branch_prefix, "bitfun/"); + assert!(!defaults.copy_local_changes); + } + + #[test] + fn worktree_errors_render_the_stable_wire_code() { + let error = WorktreeError { + code: WorktreeErrorCode::DirtyWorktree, + message: "local changes".to_string(), + recovery_path: None, + }; + + assert_eq!(error.to_string(), "dirty_worktree: local changes"); + } +} diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index 184658b1e5..3a50b580ee 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -1,6 +1,6 @@ //! Agentic Events Definition pub use bitfun_core_types::errors::{AiErrorDetail, ErrorCategory}; -use bitfun_core_types::ToolImageAttachment; +use bitfun_core_types::{SessionExecutionTarget, ToolImageAttachment}; use serde::{Deserialize, Serialize}; use std::time::SystemTime; @@ -76,6 +76,15 @@ pub enum AgenticEvent { /// Workspace path this session belongs to. None for locally-created sessions. #[serde(skip_serializing_if = "Option::is_none")] workspace_path: Option, + /// Main project root that owns persistence for this session. + #[serde(default, skip_serializing_if = "Option::is_none")] + project_workspace_path: Option, + /// Resolved local/worktree execution target. + #[serde(default, skip_serializing_if = "Option::is_none")] + execution_target: Option, + /// Stable workspace registration associated with the execution root. + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, /// Remote SSH connection identity for sessions bound to remote workspaces. #[serde(skip_serializing_if = "Option::is_none")] remote_connection_id: Option, diff --git a/src/crates/contracts/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs index dd95254f53..e0a9df005e 100644 --- a/src/crates/contracts/events/src/frontend_projection.rs +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -29,6 +29,9 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Some(AgenticFrontendEvent::new( @@ -38,6 +41,9 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, @@ -1167,6 +1177,10 @@ pub struct AgentSessionWorkspaceBinding { pub workspace_id: Option, pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_ssh_host: Option, @@ -2347,6 +2361,8 @@ mod tests { session_name: "Generated session".to_string(), agent_type: "agentic".to_string(), workspace_path: Some("/workspace/project".to_string()), + project_workspace_path: None, + execution_target: None, workspace_id: Some("workspace-1".to_string()), remote_connection_id: None, remote_ssh_host: None, @@ -3102,6 +3118,8 @@ mod tests { let workspace_binding = AgentSessionWorkspaceBinding { workspace_id: Some("workspace_1".to_string()), workspace_path: "/workspace/project".to_string(), + project_workspace_path: None, + execution_target: None, remote_connection_id: Some("conn-1".to_string()), remote_ssh_host: Some("host-1".to_string()), }; diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index 5787738565..40b4d218cf 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -1327,6 +1327,8 @@ impl AgentRuntime { session_name, agent_type, workspace_path, + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -1530,6 +1532,8 @@ mod tests { Ok(Some(AgentSessionWorkspaceBinding { workspace_id: Some("workspace_1".to_string()), workspace_path: "/workspace/project".to_string(), + project_workspace_path: None, + execution_target: None, remote_connection_id: Some("conn-1".to_string()), remote_ssh_host: Some("host-1".to_string()), })) @@ -1832,6 +1836,8 @@ mod tests { session_name: "Fixed session".to_string(), agent_type: "agentic".to_string(), workspace_path: Some("/workspace/project".to_string()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -1861,6 +1867,8 @@ mod tests { session_name: "Fixed session".to_string(), agent_type: "agentic".to_string(), workspace_path: Some("/workspace/project".to_string()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, diff --git a/src/crates/execution/agent-runtime/src/session.rs b/src/crates/execution/agent-runtime/src/session.rs index b599a4ae45..dd730aa60d 100644 --- a/src/crates/execution/agent-runtime/src/session.rs +++ b/src/crates/execution/agent-runtime/src/session.rs @@ -1,6 +1,8 @@ use crate::session_state::SessionState; pub use bitfun_core_types::SessionKind; -pub use bitfun_core_types::{SessionContinuationPolicy, SessionModelBindingPolicy}; +pub use bitfun_core_types::{ + SessionContinuationPolicy, SessionExecutionTarget, SessionModelBindingPolicy, +}; use serde::{Deserialize, Serialize}; use std::time::SystemTime; use uuid::Uuid; @@ -146,6 +148,15 @@ pub struct SessionConfig { /// without changing the desktop's foreground workspace. #[serde(skip_serializing_if = "Option::is_none")] pub workspace_path: Option, + /// Main project root used for session persistence and project-scoped + /// orchestration. For legacy and local sessions this is the same as + /// `workspace_path`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_path: Option, + /// Resolved execution target. Legacy sessions omit this and are treated as + /// local sessions rooted at `workspace_path`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_target: Option, /// Stable workspace id for resolving workspace-scoped metadata such as related directories. #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace_id: Option, @@ -191,6 +202,8 @@ impl Default for SessionConfig { max_turns: 200, enable_context_compression: true, workspace_path: None, + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 2f6c8f3776..9464ae505f 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -178,6 +178,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "GetMCPPrompt", "GenerativeUI", "Git", + "Worktree", "ReviewPlatform", "InitMiniApp", "PageDeploy", @@ -394,6 +395,7 @@ mod tests { "GetMCPPrompt", "GenerativeUI", "Git", + "Worktree", "ReviewPlatform", "InitMiniApp", "PageDeploy", diff --git a/src/crates/interfaces/acp/src/runtime/session.rs b/src/crates/interfaces/acp/src/runtime/session.rs index 1e547ebc73..9488411c6b 100644 --- a/src/crates/interfaces/acp/src/runtime/session.rs +++ b/src/crates/interfaces/acp/src/runtime/session.rs @@ -58,6 +58,8 @@ impl BitfunAcpRuntime { ), agent_type: "agentic".to_string(), workspace_path: Some(cwd.clone()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, diff --git a/src/crates/interfaces/sdk-host/src/host.rs b/src/crates/interfaces/sdk-host/src/host.rs index f5bdd1a0f6..828f97c20a 100644 --- a/src/crates/interfaces/sdk-host/src/host.rs +++ b/src/crates/interfaces/sdk-host/src/host.rs @@ -753,6 +753,8 @@ impl SdkHostConnection { .unwrap_or_else(|| DEFAULT_SESSION_NAME.to_string()), agent_type: params.agent.unwrap_or_else(|| DEFAULT_AGENT.to_string()), workspace_path: Some(workspace_path.clone()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, @@ -877,6 +879,8 @@ impl SdkHostConnection { .clone() .unwrap_or_else(|| DEFAULT_AGENT.to_string()), workspace_path: Some(workspace_path.clone()), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: None, remote_ssh_host: None, diff --git a/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs b/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs index 45a704efe3..bbd28f12a6 100644 --- a/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs +++ b/src/crates/interfaces/sdk-host/tests/host_lifecycle.rs @@ -387,6 +387,8 @@ impl AgentSessionManagementPort for FakeOwner { Ok(Some(AgentSessionWorkspaceBinding { workspace_id: None, workspace_path: "D:/workspace/project".to_string(), + project_workspace_path: None, + execution_target: None, remote_connection_id: None, remote_ssh_host: None, })) diff --git a/src/crates/services/services-core/src/json_store.rs b/src/crates/services/services-core/src/json_store.rs index 19cb57eb96..c7666b2b91 100644 --- a/src/crates/services/services-core/src/json_store.rs +++ b/src/crates/services/services-core/src/json_store.rs @@ -97,7 +97,11 @@ impl JsonFileStoreError { #[derive(Debug, Default, Clone, Copy)] pub struct JsonFileStore; -struct JsonFileCrossProcessLock(std::fs::File); +/// Held OS advisory lock for a JSON-owned transaction. +/// +/// Callers may use this guard when a read/modify/write transaction includes +/// additional side effects that cannot fit inside [`JsonFileStore::update_locked`]. +pub struct JsonFileCrossProcessLock(std::fs::File); impl Drop for JsonFileCrossProcessLock { fn drop(&mut self) { @@ -320,7 +324,7 @@ impl JsonFileStore { lock } - async fn acquire_cross_process_lock( + pub async fn acquire_cross_process_lock( &self, path: &Path, ) -> Result { diff --git a/src/crates/services/services-core/src/session/metadata.rs b/src/crates/services/services-core/src/session/metadata.rs index 009df6c906..a629cd56f6 100644 --- a/src/crates/services/services-core/src/session/metadata.rs +++ b/src/crates/services/services-core/src/session/metadata.rs @@ -4,7 +4,7 @@ use super::types::{ DialogTurnData, DialogTurnKind, SessionMemoryMode, SessionMetadata, SessionRelationship, SessionRelationshipKind, StoredSessionIndexFile, TurnStatus, }; -use bitfun_core_types::SessionKind; +use bitfun_core_types::{SessionExecutionTarget, SessionKind}; use serde_json::Value; #[derive(Debug, Clone)] @@ -22,6 +22,8 @@ pub struct SessionMetadataBuildFacts<'a> { pub turn_count: usize, pub snapshot_session_id: Option<&'a str>, pub workspace_path: &'a str, + pub project_workspace_path: Option<&'a str>, + pub execution_target: Option<&'a SessionExecutionTarget>, pub workspace_hostname: Option<&'a str>, pub new_session_memory_mode: SessionMemoryMode, pub existing: Option<&'a SessionMetadata>, @@ -76,6 +78,14 @@ pub fn build_session_metadata(facts: SessionMetadataBuildFacts<'_>) -> SessionMe review_target_evidence: existing.and_then(|value| value.review_target_evidence.clone()), deep_review_cache: existing.and_then(|value| value.deep_review_cache.clone()), workspace_path: Some(facts.workspace_path.to_string()), + project_workspace_path: facts + .project_workspace_path + .map(str::to_string) + .or_else(|| existing.and_then(|value| value.project_workspace_path.clone())), + execution_target: facts + .execution_target + .cloned() + .or_else(|| existing.and_then(|value| value.execution_target.clone())), workspace_hostname: facts.workspace_hostname.map(str::to_string), unread_completion: existing.and_then(|value| value.unread_completion.clone()), needs_user_attention: existing.and_then(|value| value.needs_user_attention.clone()), @@ -367,7 +377,10 @@ fn fill_workspace_path_if_missing(metadata: &mut SessionMetadata, workspace_path mod tests { use super::*; use crate::session::{SessionRelationship, SessionRelationshipKind}; - use bitfun_core_types::SessionContinuationPolicy; + use bitfun_core_types::{ + SessionContinuationPolicy, SessionExecutionTarget, SessionExecutionTargetKind, + WorktreeLifecycle, + }; use serde_json::json; fn metadata() -> SessionMetadata { @@ -508,6 +521,8 @@ mod tests { turn_count: 4, snapshot_session_id: Some("snapshot-1"), workspace_path: "/workspace", + project_workspace_path: None, + execution_target: None, workspace_hostname: Some("host"), new_session_memory_mode: crate::session::SessionMemoryMode::Enabled, existing: Some(&existing), @@ -563,6 +578,8 @@ mod tests { turn_count: 0, snapshot_session_id: None, workspace_path: "/workspace", + project_workspace_path: None, + execution_target: None, workspace_hostname: None, new_session_memory_mode: crate::session::SessionMemoryMode::Disabled, existing: None, @@ -573,4 +590,41 @@ mod tests { crate::session::SessionMemoryMode::Disabled ); } + + #[test] + fn build_session_metadata_persists_dual_roots_and_execution_target() { + let execution_target = SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("wt-1".to_string()), + root_path: "/worktrees/wt-1".to_string(), + base_ref: Some("main".to_string()), + base_commit: Some("0123456789abcdef".to_string()), + branch: None, + lifecycle: Some(WorktreeLifecycle::Managed), + }; + let built = build_session_metadata(SessionMetadataBuildFacts { + session_id: "session-worktree", + session_name: "Isolated session", + agent_type: "agentic", + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, + created_by: None, + session_kind: crate::session::SessionKind::Standard, + model_name: Some("gpt-test"), + created_at_ms: 100, + last_active_at_ms: 200, + turn_count: 0, + snapshot_session_id: None, + workspace_path: "/worktrees/wt-1", + project_workspace_path: Some("/repo"), + execution_target: Some(&execution_target), + workspace_hostname: None, + new_session_memory_mode: crate::session::SessionMemoryMode::Enabled, + existing: None, + }); + + assert_eq!(built.workspace_path.as_deref(), Some("/worktrees/wt-1")); + assert_eq!(built.project_workspace_path.as_deref(), Some("/repo")); + assert_eq!(built.execution_target, Some(execution_target)); + } } diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index 9d9e5cad53..9e58454a1f 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -1,7 +1,9 @@ //! Types for session persistence use bitfun_core_types::ToolImageAttachment; -use bitfun_core_types::{AiErrorDetail, SessionContinuationPolicy, SessionKind}; +use bitfun_core_types::{ + AiErrorDetail, SessionContinuationPolicy, SessionExecutionTarget, SessionKind, +}; use bitfun_events::ModelRoundAttemptDiagnostic; use serde::{Deserialize, Serialize}; @@ -236,6 +238,23 @@ pub struct SessionMetadata { #[serde(skip_serializing_if = "Option::is_none", alias = "workspace_path")] pub workspace_path: Option, + /// Main project path that owns this session's persisted data. Legacy + /// sessions omit it and use `workspace_path`. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "project_workspace_path" + )] + pub project_workspace_path: Option, + + /// Concrete execution target, including managed worktree identity. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "execution_target" + )] + pub execution_target: Option, + /// Unified hostname for workspace identity: `localhost` for local workspaces, /// SSH host for remote workspaces. #[serde( @@ -908,6 +927,8 @@ impl SessionMetadata { review_target_evidence: None, deep_review_cache: None, workspace_path: None, + project_workspace_path: None, + execution_target: None, workspace_hostname: None, unread_completion: None, needs_user_attention: None, diff --git a/src/crates/services/services-integrations/src/git/managed_worktree.rs b/src/crates/services/services-integrations/src/git/managed_worktree.rs new file mode 100644 index 0000000000..0e7d8b927c --- /dev/null +++ b/src/crates/services/services-integrations/src/git/managed_worktree.rs @@ -0,0 +1,681 @@ +use super::service::GitService; +use super::types::{GitLocalChangeSummary, GitWorktreeInfo}; +use super::utils::execute_git_command; +use super::GitError; +use bitfun_services_core::process_manager; +use git2::Repository; +use std::path::{Component, Path, PathBuf}; +use std::process::Stdio; +use tokio::io::AsyncWriteExt; +use tokio::task; + +fn normalized_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn parse_nul_paths(bytes: &[u8]) -> Result, GitError> { + bytes + .split(|byte| *byte == 0) + .filter(|value| !value.is_empty()) + .map(|value| { + String::from_utf8(value.to_vec()).map_err(|error| { + GitError::ParseError(format!("Git returned a non-UTF-8 path: {error}")) + }) + }) + .collect() +} + +fn validate_relative_file_path(path: &str) -> Result { + if path.trim().is_empty() || path.contains('\0') { + return Err(GitError::InvalidPath( + "Worktree copy paths must be non-empty relative paths".to_string(), + )); + } + let path = PathBuf::from(path); + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + || path.components().any(|component| { + matches!( + component, + Component::Normal(name) + if name.to_string_lossy().eq_ignore_ascii_case(".git") + ) + }) + { + return Err(GitError::InvalidPath(format!( + "Worktree copy path escapes the repository: {}", + path.display() + ))); + } + Ok(path) +} + +async fn git_output_bytes(repo_path: &Path, args: &[&str]) -> Result, GitError> { + let output = process_manager::create_tokio_command("git") + .current_dir(repo_path) + .env("GIT_TERMINAL_PROMPT", "0") + .args(args) + .output() + .await + .map_err(|error| { + GitError::CommandFailed(format!("Failed to execute git command: {error}")) + })?; + if output.status.success() { + Ok(output.stdout) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Err(GitError::CommandFailed(if stderr.is_empty() { + stdout + } else { + stderr + })) + } +} + +async fn git_with_stdin(repo_path: &Path, args: &[&str], input: &[u8]) -> Result<(), GitError> { + if input.is_empty() { + return Ok(()); + } + + let mut child = process_manager::create_tokio_command("git"); + child + .current_dir(repo_path) + .env("GIT_TERMINAL_PROMPT", "0") + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = child.spawn().map_err(|error| { + GitError::CommandFailed(format!("Failed to execute git command: {error}")) + })?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| GitError::CommandFailed("Failed to open git stdin".to_string()))?; + stdin + .write_all(input) + .await + .map_err(|error| GitError::CommandFailed(format!("Failed to write git stdin: {error}")))?; + drop(stdin); + + let output = child.wait_with_output().await.map_err(|error| { + GitError::CommandFailed(format!("Failed to wait for git command: {error}")) + })?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Err(GitError::CommandFailed(if stderr.is_empty() { + stdout + } else { + stderr + })) + } +} + +async fn ignored_include_paths(source: &Path) -> Result, GitError> { + let include_path = source.join(".worktreeinclude"); + let metadata = match tokio::fs::symlink_metadata(&include_path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(GitError::IoError(error)), + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(GitError::InvalidPath( + ".worktreeinclude must be a regular file".to_string(), + )); + } + let contents = tokio::fs::read_to_string(&include_path) + .await + .map_err(GitError::IoError)?; + let patterns = contents + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_string) + .collect::>(); + for pattern in &patterns { + validate_relative_file_path(pattern)?; + } + if patterns.is_empty() { + return Ok(Vec::new()); + } + + let mut owned_args = vec![ + "ls-files".to_string(), + "--others".to_string(), + "--ignored".to_string(), + "--exclude-standard".to_string(), + "-z".to_string(), + "--".to_string(), + ]; + owned_args.extend(patterns); + let args = owned_args.iter().map(String::as_str).collect::>(); + parse_nul_paths(&git_output_bytes(source, &args).await?) +} + +async fn copy_regular_files( + source_root: &Path, + target_root: &Path, + paths: &[String], +) -> Result<(), GitError> { + let source_root = source_root.to_path_buf(); + let target_root = target_root.to_path_buf(); + let paths = paths.to_vec(); + task::spawn_blocking(move || { + fn validate_ancestors( + root: &Path, + relative: &Path, + require_existing: bool, + ) -> Result<(), GitError> { + let root_metadata = std::fs::symlink_metadata(root).map_err(GitError::IoError)?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err(GitError::InvalidPath(format!( + "Worktree copy root must be a regular directory: {}", + root.display() + ))); + } + + let mut current = root.to_path_buf(); + if let Some(parent) = relative.parent() { + for component in parent.components() { + current.push(component.as_os_str()); + match std::fs::symlink_metadata(¤t) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(GitError::InvalidPath(format!( + "Worktree copy does not follow symlink or non-directory ancestors: {}", + current.display() + ))); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if require_existing { + return Err(GitError::IoError(error)); + } + break; + } + Err(error) => return Err(GitError::IoError(error)), + } + } + } + Ok(()) + } + + for relative in paths { + let relative = validate_relative_file_path(&relative)?; + validate_ancestors(&source_root, &relative, true)?; + validate_ancestors(&target_root, &relative, false)?; + let source = source_root.join(&relative); + let target = target_root.join(&relative); + let metadata = std::fs::symlink_metadata(&source).map_err(GitError::IoError)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(GitError::InvalidPath(format!( + "Worktree copy only accepts regular files: {}", + relative.display() + ))); + } + match std::fs::symlink_metadata(&target) { + Ok(_) => { + return Err(GitError::InvalidPath(format!( + "Worktree copy would overwrite an existing file: {}", + relative.display() + ))); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(GitError::IoError(error)), + } + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(GitError::IoError)?; + } + std::fs::copy(&source, &target).map_err(GitError::IoError)?; + } + Ok(()) + }) + .await + .map_err(|error| GitError::CommandFailed(format!("spawn_blocking join: {error}")))? +} + +impl GitService { + /// Creates an explicit-path detached worktree at an immutable commit. + pub async fn add_detached_worktree, Q: AsRef>( + repository_path: P, + target_path: Q, + commit: &str, + ) -> Result { + let repository_path = repository_path.as_ref().to_path_buf(); + let target_path = target_path.as_ref().to_path_buf(); + let commit = commit.trim().to_string(); + if commit.is_empty() { + return Err(GitError::CommandFailed( + "Detached worktree requires an immutable commit".to_string(), + )); + } + match tokio::fs::symlink_metadata(&target_path).await { + Ok(_) => { + return Err(GitError::InvalidPath(format!( + "Worktree target already exists: {}", + target_path.display() + ))); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(GitError::IoError(error)), + } + if let Some(parent) = target_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(GitError::IoError)?; + } + + let repository = normalized_path(&repository_path); + let target = normalized_path(&target_path); + execute_git_command( + &repository, + &["worktree", "add", "--detach", &target, &commit], + ) + .await?; + + let inspect_path = target_path.clone(); + task::spawn_blocking(move || { + let repository = Repository::open(&inspect_path).map_err(|error| { + GitError::CommandFailed(format!( + "Failed to inspect newly created detached worktree: {error}" + )) + })?; + let head = repository + .head() + .ok() + .and_then(|head| head.target()) + .map(|target| target.to_string()) + .unwrap_or_default(); + Ok(GitWorktreeInfo { + path: normalized_path(&inspect_path), + branch: None, + head, + is_main: false, + is_locked: false, + is_prunable: false, + }) + }) + .await + .map_err(|error| GitError::CommandFailed(format!("spawn_blocking join: {error}")))? + } + + /// Creates a local branch for an existing detached worktree. + pub async fn create_worktree_branch>( + worktree_path: P, + branch: &str, + ) -> Result { + let worktree_path = worktree_path.as_ref().to_path_buf(); + let path = normalized_path(&worktree_path); + execute_git_command(&path, &["check-ref-format", "--branch", branch]).await?; + execute_git_command(&path, &["switch", "-c", branch]).await?; + let head = execute_git_command(&path, &["rev-parse", "HEAD"]) + .await? + .trim() + .to_string(); + Ok(GitWorktreeInfo { + path, + branch: Some(branch.to_string()), + head, + is_main: false, + is_locked: false, + is_prunable: false, + }) + } + + /// Reattaches an existing branch while recreating a missing registered + /// worktree. + pub async fn attach_worktree_branch>( + worktree_path: P, + branch: &str, + ) -> Result { + let worktree_path = worktree_path.as_ref().to_path_buf(); + let path = normalized_path(&worktree_path); + execute_git_command(&path, &["check-ref-format", "--branch", branch]).await?; + execute_git_command(&path, &["switch", branch]).await?; + let head = execute_git_command(&path, &["rev-parse", "HEAD"]) + .await? + .trim() + .to_string(); + Ok(GitWorktreeInfo { + path, + branch: Some(branch.to_string()), + head, + is_main: false, + is_locked: false, + is_prunable: false, + }) + } + + pub async fn prune_worktrees>(repository_path: P) -> Result<(), GitError> { + execute_git_command( + &normalized_path(repository_path.as_ref()), + &["worktree", "prune"], + ) + .await?; + Ok(()) + } + + pub async fn worktree_is_dirty>(worktree_path: P) -> Result { + let worktree_path = worktree_path.as_ref(); + let status = Self::get_status(worktree_path).await?; + Ok(!status.staged.is_empty() + || !status.unstaged.is_empty() + || !status.untracked.is_empty() + || !status.conflicts.is_empty() + // Files explicitly selected by `.worktreeinclude` are copied user + // state even though Git ignores them. Treat them as dirty so safe + // removal cannot silently discard the copied state. + || !ignored_include_paths(worktree_path).await?.is_empty()) + } + + /// Returns true when detached HEAD contains commits unreachable from every + /// local or remote ref. + pub async fn worktree_has_unpublished_commits>( + worktree_path: P, + ) -> Result { + let worktree_path = worktree_path.as_ref(); + let repository = Repository::open(worktree_path) + .map_err(|error| GitError::RepositoryNotFound(error.to_string()))?; + if repository.head().ok().is_some_and(|head| head.is_branch()) { + return Ok(false); + } + let path = normalized_path(worktree_path); + let refs = execute_git_command( + &path, + &["for-each-ref", "--format=%(refname)", "--contains", "HEAD"], + ) + .await?; + Ok(refs.trim().is_empty()) + } + + pub async fn local_change_summary>( + source_path: P, + ) -> Result { + let source = source_path.as_ref(); + let staged = parse_nul_paths( + &git_output_bytes( + source, + &["diff", "--name-only", "-z", "--cached", "HEAD", "--"], + ) + .await?, + )?; + let unstaged = parse_nul_paths( + &git_output_bytes(source, &["diff", "--name-only", "-z", "--"]).await?, + )?; + let untracked = parse_nul_paths( + &git_output_bytes( + source, + &["ls-files", "--others", "--exclude-standard", "-z"], + ) + .await?, + )?; + let included_ignored = ignored_include_paths(source).await?; + Ok(GitLocalChangeSummary { + staged, + unstaged, + untracked, + included_ignored, + }) + } + + /// Copies source changes into a fresh worktree while preserving index state. + /// Both worktrees must still point at the same immutable HEAD. + pub async fn copy_local_changes, Q: AsRef>( + source_path: P, + target_path: Q, + ) -> Result { + let source = source_path.as_ref(); + let target = target_path.as_ref(); + let source_head = execute_git_command(&normalized_path(source), &["rev-parse", "HEAD"]) + .await? + .trim() + .to_string(); + let target_head = execute_git_command(&normalized_path(target), &["rev-parse", "HEAD"]) + .await? + .trim() + .to_string(); + if source_head != target_head { + return Err(GitError::CommandFailed( + "Local changes can only be copied when source and target HEAD match".to_string(), + )); + } + + let summary = Self::local_change_summary(source).await?; + let staged_patch = + git_output_bytes(source, &["diff", "--binary", "--cached", "HEAD", "--"]).await?; + git_with_stdin( + target, + &["apply", "--binary", "--index", "--whitespace=nowarn", "-"], + &staged_patch, + ) + .await?; + + let unstaged_patch = git_output_bytes(source, &["diff", "--binary", "--"]).await?; + git_with_stdin( + target, + &["apply", "--binary", "--whitespace=nowarn", "-"], + &unstaged_patch, + ) + .await?; + + copy_regular_files(source, target, &summary.untracked).await?; + copy_regular_files(source, target, &summary.included_ignored).await?; + Ok(summary) + } +} + +#[cfg(test)] +mod tests { + use super::GitService; + use std::fs; + use std::path::Path; + use std::process::Command; + use tempfile::TempDir; + + fn git(path: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .current_dir(path) + .env("GIT_TERMINAL_PROMPT", "0") + .args(args) + .output() + .expect("git command should start"); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + + fn initialized_repository() -> (TempDir, std::path::PathBuf) { + let temp = TempDir::new().expect("temp dir"); + let repository = temp.path().join("repository"); + fs::create_dir_all(&repository).expect("create repository"); + git(&repository, &["init"]); + git(&repository, &["config", "user.name", "BitFun Test"]); + git( + &repository, + &["config", "user.email", "bitfun-test@example.invalid"], + ); + fs::write(repository.join("shared.txt"), "base\n").expect("write base file"); + git(&repository, &["add", "."]); + git(&repository, &["commit", "-m", "base"]); + (temp, repository) + } + + #[tokio::test] + async fn detached_worktrees_from_the_same_commit_are_independent() { + let (temp, repository) = initialized_repository(); + let base = git(&repository, &["rev-parse", "HEAD"]); + let first = temp.path().join("first"); + let second = temp.path().join("second"); + + let first_result = GitService::add_detached_worktree(&repository, &first, &base) + .await + .expect("first worktree"); + let second_result = GitService::add_detached_worktree(&repository, &second, &base) + .await + .expect("second worktree"); + assert_eq!(first_result.head, base); + assert_eq!(second_result.head, base); + + fs::write(first.join("shared.txt"), "first\n").expect("write first"); + fs::write(second.join("shared.txt"), "second\n").expect("write second"); + + assert_eq!( + fs::read_to_string(repository.join("shared.txt")).unwrap(), + "base\n" + ); + assert_eq!( + fs::read_to_string(first.join("shared.txt")).unwrap(), + "first\n" + ); + assert_eq!( + fs::read_to_string(second.join("shared.txt")).unwrap(), + "second\n" + ); + + git(&first, &["add", "shared.txt"]); + git(&first, &["commit", "-m", "detached change"]); + assert!(GitService::worktree_has_unpublished_commits(&first) + .await + .expect("unpublished check")); + assert!(!GitService::worktree_has_unpublished_commits(&second) + .await + .expect("base commit is referenced")); + } + + #[tokio::test] + async fn local_changes_copy_preserves_index_binary_and_selected_files() { + let (temp, repository) = initialized_repository(); + fs::write(repository.join("binary.dat"), [0_u8, 1, 2, 3]).expect("write binary"); + fs::write(repository.join(".gitignore"), "secret.env\n").expect("write ignore"); + fs::write(repository.join(".worktreeinclude"), "secret.env\n").expect("write include"); + git(&repository, &["add", "."]); + git(&repository, &["commit", "-m", "copy fixtures"]); + + fs::write(repository.join("shared.txt"), "staged\n").expect("write staged"); + fs::write(repository.join("binary.dat"), [0_u8, 9, 8, 7, 6]).expect("write binary change"); + git(&repository, &["add", "shared.txt", "binary.dat"]); + fs::write(repository.join("shared.txt"), "staged\nunstaged\n").expect("write unstaged"); + fs::write(repository.join("untracked.txt"), "untracked\n").expect("write untracked"); + fs::write(repository.join("secret.env"), "included ignored\n") + .expect("write selected ignored"); + + let base = git(&repository, &["rev-parse", "HEAD"]); + let target = temp.path().join("copy-target"); + GitService::add_detached_worktree(&repository, &target, &base) + .await + .expect("create target"); + let summary = GitService::copy_local_changes(&repository, &target) + .await + .expect("copy changes"); + + assert!(summary.staged.contains(&"shared.txt".to_string())); + assert!(summary.staged.contains(&"binary.dat".to_string())); + assert!(summary.unstaged.contains(&"shared.txt".to_string())); + assert!(summary.untracked.contains(&"untracked.txt".to_string())); + assert!(summary.included_ignored.contains(&"secret.env".to_string())); + assert_eq!( + fs::read_to_string(target.join("shared.txt")).unwrap(), + "staged\nunstaged\n" + ); + assert_eq!( + fs::read(target.join("binary.dat")).unwrap(), + [0_u8, 9, 8, 7, 6] + ); + assert_eq!( + fs::read_to_string(target.join("untracked.txt")).unwrap(), + "untracked\n" + ); + assert_eq!( + fs::read_to_string(target.join("secret.env")).unwrap(), + "included ignored\n" + ); + + let staged = git(&target, &["diff", "--cached", "--name-only"]); + let unstaged = git(&target, &["diff", "--name-only"]); + assert!(staged.lines().any(|path| path == "shared.txt")); + assert!(staged.lines().any(|path| path == "binary.dat")); + assert!(unstaged.lines().any(|path| path == "shared.txt")); + } + + #[cfg(unix)] + #[tokio::test] + async fn local_changes_copy_rejects_selected_symlinks() { + use std::os::unix::fs::symlink; + + let (temp, repository) = initialized_repository(); + fs::write(repository.join(".gitignore"), "selected-link\n").expect("write ignore"); + fs::write(repository.join(".worktreeinclude"), "selected-link\n").expect("write include"); + git(&repository, &["add", "."]); + git(&repository, &["commit", "-m", "symlink fixtures"]); + + let outside = temp.path().join("outside-secret"); + fs::write(&outside, "must not copy\n").expect("write outside file"); + symlink(&outside, repository.join("selected-link")).expect("create selected symlink"); + + let base = git(&repository, &["rev-parse", "HEAD"]); + let target = temp.path().join("symlink-target"); + GitService::add_detached_worktree(&repository, &target, &base) + .await + .expect("create target"); + let error = GitService::copy_local_changes(&repository, &target) + .await + .expect_err("selected symlink must be rejected"); + + assert!(error.to_string().contains("regular files")); + assert!(!target.join("selected-link").exists()); + } + + #[tokio::test] + async fn selected_ignored_files_block_safe_clean_removal() { + let (temp, repository) = initialized_repository(); + fs::write(repository.join(".gitignore"), "secret.env\n").expect("write ignore"); + fs::write(repository.join(".worktreeinclude"), "secret.env\n").expect("write include"); + git(&repository, &["add", "."]); + git(&repository, &["commit", "-m", "include policy"]); + + let base = git(&repository, &["rev-parse", "HEAD"]); + let target = temp.path().join("ignored-state-target"); + GitService::add_detached_worktree(&repository, &target, &base) + .await + .expect("create target"); + fs::write(target.join("secret.env"), "user state\n").expect("write selected ignored file"); + + assert!( + GitService::worktree_is_dirty(&target) + .await + .expect("dirty check"), + "selected ignored state must be protected by safe removal" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn detached_worktree_rejects_a_dangling_symlink_target() { + use std::os::unix::fs::symlink; + + let (temp, repository) = initialized_repository(); + let base = git(&repository, &["rev-parse", "HEAD"]); + let target = temp.path().join("dangling-target"); + symlink(temp.path().join("missing-target"), &target).expect("create dangling symlink"); + + let error = GitService::add_detached_worktree(&repository, &target, &base) + .await + .expect_err("dangling targets must not be followed"); + + assert!(error.to_string().contains("already exists")); + } +} diff --git a/src/crates/services/services-integrations/src/git/mod.rs b/src/crates/services/services-integrations/src/git/mod.rs index 09f06b0394..4139df244c 100644 --- a/src/crates/services/services-integrations/src/git/mod.rs +++ b/src/crates/services/services-integrations/src/git/mod.rs @@ -6,6 +6,7 @@ pub mod args; pub mod error; pub mod graph; +mod managed_worktree; pub mod name_status; pub mod service; pub mod text; diff --git a/src/crates/services/services-integrations/src/git/types.rs b/src/crates/services/services-integrations/src/git/types.rs index 9eb6ac326d..3a485c8241 100644 --- a/src/crates/services/services-integrations/src/git/types.rs +++ b/src/crates/services/services-integrations/src/git/types.rs @@ -256,6 +256,25 @@ pub struct GitWorktreeInfo { pub is_prunable: bool, } +/// Local changes that can be copied into a freshly-created detached worktree. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitLocalChangeSummary { + pub staged: Vec, + pub unstaged: Vec, + pub untracked: Vec, + pub included_ignored: Vec, +} + +impl GitLocalChangeSummary { + pub fn is_empty(&self) -> bool { + self.staged.is_empty() + && self.unstaged.is_empty() + && self.untracked.is_empty() + && self.included_ignored.is_empty() + } +} + /// Repository identity used to share worktree topology across linked checkouts. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GitWorktreeRepositoryInfo { diff --git a/src/crates/services/services-integrations/src/git/worktree.rs b/src/crates/services/services-integrations/src/git/worktree.rs index 56b13601dd..6c6fa8c9d3 100644 --- a/src/crates/services/services-integrations/src/git/worktree.rs +++ b/src/crates/services/services-integrations/src/git/worktree.rs @@ -31,9 +31,9 @@ pub fn parse_worktree_list(output: &str) -> Vec { wt.branch = Some(branch_name); } else if line == "bare" { wt.is_main = true; - } else if line == "locked" { + } else if line == "locked" || line.starts_with("locked ") { wt.is_locked = true; - } else if line == "prunable" { + } else if line == "prunable" || line.starts_with("prunable ") { wt.is_prunable = true; } } @@ -51,3 +51,22 @@ pub fn parse_worktree_list(output: &str) -> Vec { worktrees } + +#[cfg(test)] +mod tests { + use super::parse_worktree_list; + + #[test] + fn parses_lock_and_prunable_reasons() { + let worktrees = parse_worktree_list( + "worktree /repo\nHEAD 1111111111111111111111111111111111111111\nbranch refs/heads/main\n\n\ + worktree /repo/missing\nHEAD 2222222222222222222222222222222222222222\n\ + detached\nlocked in use\nprunable gitdir file points to non-existent location\n", + ); + + assert_eq!(worktrees.len(), 2); + assert!(worktrees[0].is_main); + assert!(worktrees[1].is_locked); + assert!(worktrees[1].is_prunable); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 8232e1f665..d6ebaa42ac 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -70,6 +70,14 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; use tokio::io::{AsyncReadExt, AsyncSeekExt}; +pub(crate) fn bitfun_home_dir() -> Option { + std::env::var_os("BITFUN_HOME") + .or_else(|| std::env::var_os("BITFUN_E2E_HOME")) + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .or_else(|| dirs::home_dir().map(|home| home.join(".bitfun"))) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RemoteConnectSubmissionSource { @@ -110,6 +118,8 @@ pub fn build_remote_session_create_request( session_name: session_name.into(), agent_type: agent_type.into(), workspace_path: workspace_path.map(Into::into), + project_workspace_path: None, + execution_target: None, workspace_id: None, remote_connection_id: workspace_identity.remote_connection_id, remote_ssh_host: workspace_identity.remote_ssh_host, diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs index 15a9d0a7da..bd90d22573 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/mod.rs @@ -497,10 +497,7 @@ fn bot_persistence_lock() -> std::sync::MutexGuard<'static, ()> { } fn bot_persistence_path() -> Option { - dirs::home_dir().map(|home| { - home.join(".bitfun") - .join(REMOTE_CONNECT_PERSISTENCE_FILENAME) - }) + super::bitfun_home_dir().map(|home| home.join(REMOTE_CONNECT_PERSISTENCE_FILENAME)) } fn bot_persistence_backup_path(path: &std::path::Path) -> std::path::PathBuf { @@ -512,7 +509,7 @@ fn bot_persistence_backup_path(path: &std::path::Path) -> std::path::PathBuf { } fn legacy_bot_persistence_path() -> Option { - dirs::home_dir().map(|home| home.join(".bitfun").join(LEGACY_BOT_PERSISTENCE_FILENAME)) + super::bitfun_home_dir().map(|home| home.join(LEGACY_BOT_PERSISTENCE_FILENAME)) } fn load_bot_persistence_unlocked() -> BotPersistenceData { diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/weixin.rs b/src/crates/services/services-integrations/src/remote_connect/bot/weixin.rs index a8fb77ddfe..3bf6cefe4a 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/weixin.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/weixin.rs @@ -1342,9 +1342,9 @@ fn ensure_trailing_slash(url: &str) -> String { } fn sync_buf_path(bot_account_id: &str) -> PathBuf { - let base = dirs::home_dir().unwrap_or_else(std::env::temp_dir); - base.join(".bitfun") - .join("weixin") + let base = + super::super::bitfun_home_dir().unwrap_or_else(|| std::env::temp_dir().join(".bitfun")); + base.join("weixin") .join(format!("{bot_account_id}_get_updates_buf.txt")) } diff --git a/src/crates/services/services-integrations/src/remote_connect/device.rs b/src/crates/services/services-integrations/src/remote_connect/device.rs index a81bee77ff..90eae3bc37 100644 --- a/src/crates/services/services-integrations/src/remote_connect/device.rs +++ b/src/crates/services/services-integrations/src/remote_connect/device.rs @@ -1,6 +1,7 @@ //! Device identity for Remote Connect pairing and account device routing. //! -//! `device_id` is generated once and persisted under `~/.bitfun/device_identity.json`. +//! `device_id` is generated once and persisted under +//! `/device_identity.json` (normally `~/.bitfun/device_identity.json`). //! Hostname/MAC are refreshed for display only — they must not rewrite `device_id`, //! because macOS private Wi‑Fi addresses and interface order make MAC unstable. @@ -114,8 +115,9 @@ fn identity_file_path() -> Result { } } - let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot determine home directory"))?; - Ok(home.join(".bitfun").join("device_identity.json")) + let home = super::bitfun_home_dir() + .ok_or_else(|| anyhow!("cannot determine BitFun home directory"))?; + Ok(home.join("device_identity.json")) } fn load_persisted() -> Result> { diff --git a/src/crates/services/services-integrations/src/remote_connect/session_store.rs b/src/crates/services/services-integrations/src/remote_connect/session_store.rs index dddcc72eee..fa9e585e5a 100644 --- a/src/crates/services/services-integrations/src/remote_connect/session_store.rs +++ b/src/crates/services/services-integrations/src/remote_connect/session_store.rs @@ -7,7 +7,8 @@ //! requiring a fresh password entry while keeping copied session ciphertext //! unusable without the separate install key. //! -//! File location: `~/.bitfun/account_session.enc` +//! File location: `/account_session.enc` when configured, +//! otherwise `~/.bitfun/account_session.enc`. //! Format: base64(nonce || ciphertext) where the plaintext is a JSON //! payload `{ token, user_id, master_key_b64, relay_url }`. @@ -48,8 +49,7 @@ fn session_store_directory() -> Result { return Ok(path.clone()); } drop(override_path); - let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot determine home directory"))?; - Ok(home.join(".bitfun")) + super::bitfun_home_dir().ok_or_else(|| anyhow!("cannot determine BitFun home directory")) } /// The on-disk JSON payload (plaintext before encryption). diff --git a/src/crates/services/services-integrations/src/remote_connect/sync_state.rs b/src/crates/services/services-integrations/src/remote_connect/sync_state.rs index 660944afa2..297317777b 100644 --- a/src/crates/services/services-integrations/src/remote_connect/sync_state.rs +++ b/src/crates/services/services-integrations/src/remote_connect/sync_state.rs @@ -1,6 +1,7 @@ //! Local account sync cursors and upload content hashes. //! -//! Persists per-user state under `~/.bitfun/account_sync/` so incremental +//! Persists per-user state under `/account_sync/` (normally +//! `~/.bitfun/account_sync/`) so incremental //! `?since=` pulls and upload dedupe survive app restarts. Not secret — //! hashes are of plaintext session bundles; cursors are relay version ints. //! @@ -46,8 +47,9 @@ pub fn content_hash(plaintext: &str) -> String { } fn sync_dir() -> Result { - let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot determine home directory"))?; - Ok(home.join(".bitfun").join("account_sync")) + let home = super::bitfun_home_dir() + .ok_or_else(|| anyhow!("cannot determine BitFun home directory"))?; + Ok(home.join("account_sync")) } fn safe_user_id(user_id: &str) -> String { diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index 41e2d1af26..4e73b4dd50 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -13,7 +13,7 @@ import React, { useCallback, useState, useMemo, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Plus, FolderOpen, FolderPlus, History, Check, User, Users, Puzzle, Blocks, ChevronDown, Search } from 'lucide-react'; +import { Plus, FolderOpen, FolderPlus, History, Check, User, Users, Puzzle, Blocks, ChevronDown, Search, GitBranch } from 'lucide-react'; // import { PanelsTopLeft } from 'lucide-react'; // temporarily hidden: Pages nav entry import { Tooltip } from '@/component-library'; import { useApp } from '../../hooks/useApp'; @@ -23,6 +23,7 @@ import type { SceneTabId } from '../SceneBar/types'; import SectionHeader from './components/SectionHeader'; import MiniAppEntry from './components/MiniAppEntry'; import WorkspaceListSection from './sections/workspaces/WorkspaceListSection'; +import { openWorktreeLauncher } from '@/shared/services/worktreeUIEvents'; import SessionsSection from './sections/sessions/SessionsSection'; import { useSceneStore } from '../../stores/sceneStore'; import { useMyAgentStore } from '../../scenes/my-agent/myAgentStore'; @@ -76,6 +77,7 @@ const MainNav: React.FC = ({ const activeTabId = useSceneStore(s => s.activeTabId); const setSelectedAssistantWorkspaceId = useMyAgentStore((s) => s.setSelectedAssistantWorkspaceId); const { t } = useI18n('common'); + const { t: tWorktrees } = useI18n('worktrees'); // const { t: tPages } = useI18n('scenes/pages'); // temporarily hidden: Pages nav entry const { currentWorkspace, @@ -251,6 +253,21 @@ const MainNav: React.FC = ({ void handleCreateProjectSession('Cowork'); }, [handleCreateProjectSession, setSessionMode]); + const handleCreateWorktreeSession = useCallback(() => { + const target = pickWorkspaceForProjectChatSession(currentWorkspace, normalWorkspacesList); + if (!target) { + notificationService.warning(t('nav.sessions.needProjectWorkspaceForSession'), { + duration: 4500, + }); + return; + } + if (isRemoteWorkspace(target)) { + notificationService.info(tWorktrees('launcher.remoteUnsupported'), { duration: 3500 }); + return; + } + openWorktreeLauncher(target.worktree?.mainRepoPath || target.rootPath, 'agentic'); + }, [currentWorkspace, normalWorkspacesList, t, tWorktrees]); + const handleOpenProject = useCallback(async () => { try { const { pickWorkspaceDirectory } = await import( @@ -520,6 +537,21 @@ const MainNav: React.FC = ({ + + + + + + {loadError ? ( + + ) : null} + {managedWorktrees.map(worktree => { + const isCollapsed = collapsed.has(worktree.worktreeId); + const revision = worktree.branch || worktree.head.slice(0, 9); + return ( +
+
+ +
+ {lifecycleLabel(worktree)} + {worktree.dirty ? {t('labels.dirty')} : null} + {worktree.missing ? {t('labels.missing')} : null} + {worktree.runningSessionCount > 0 + ? {t('labels.running', { count: worktree.runningSessionCount })} + : null} +
+ + + +
+ {!isCollapsed ? ( + + ) : null} +
+ ); + })} + + ) : null} + + setLauncherOpen(false)} + onSubmit={createManagedSession} + /> + setManagerOpen(false)} + onRefresh={refresh} + onCreateWorktree={() => { + setLauncherMode('agentic'); + setLauncherOpen(true); + }} + onCreateSession={worktree => createSession(worktree)} + /> + + ); +}; + +export default ProjectWorktrees; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 7eb3e8794f..7f22997ede 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -8,9 +8,7 @@ import { useI18n } from '@/infrastructure/i18n'; import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { - createWorktreeWorkspace, deleteWorktreeWorkspace, - WorktreeWorkspaceCreationError, } from '@/infrastructure/services/business/worktreeWorkspaceService'; import { useNavSceneStore } from '@/app/stores/navSceneStore'; import { useApp } from '@/app/hooks/useApp'; @@ -28,8 +26,12 @@ import { import { findReusableEmptySessionId } from '@/app/utils/projectSessionWorkspace'; import type { AcpClientInfo } from '@/infrastructure/api/service-api/ACPClientAPI'; import { loadWorkspaceAcpMenuClients } from './workspaceAcpMenuClients'; -import { BranchSelectModal, type BranchSelectResult } from '../../../panels/BranchSelectModal'; import SessionsSection from '../sessions/SessionsSection'; +import ProjectWorktrees from './ProjectWorktrees'; +import { + openWorktreeLauncher, + openWorktreeManager, +} from '@/shared/services/worktreeUIEvents'; import { WorkspaceKind, isLinkedWorktreeWorkspace, @@ -88,9 +90,9 @@ const WorkspaceItem: React.FC = ({ onDragEnd, }) => { const { t } = useI18n('common'); + const { t: tWorktrees } = useI18n('worktrees'); const { t: tFiles } = useTranslation('panels/files'); const { - openWorkspace, setActiveWorkspace, closeWorkspaceById, deleteAssistantWorkspace, @@ -118,7 +120,6 @@ const WorkspaceItem: React.FC = ({ gitBasicInfoOptions ); const [menuOpen, setMenuOpen] = useState(false); - const [worktreeModalOpen, setWorktreeModalOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteWorktreeDialogOpen, setDeleteWorktreeDialogOpen] = useState(false); const [resetDialogOpen, setResetDialogOpen] = useState(false); @@ -737,36 +738,6 @@ const WorkspaceItem: React.FC = ({ } }, [setActiveWorkspace, t, workspace]); - const handleCreateWorktree = useCallback(async (result: BranchSelectResult) => { - try { - const created = await createWorktreeWorkspace({ - repositoryPath: workspace.rootPath, - branch: result.branch, - isNew: result.isNew, - openAfterCreate: result.openAfterCreate, - openWorkspace, - }); - notificationService.success( - created.openedWorkspace - ? t('nav.workspaces.worktreeCreatedAndOpened') - : t('nav.workspaces.worktreeCreated'), - { duration: 2500 }, - ); - } catch (error) { - notificationService.error( - t( - error instanceof WorktreeWorkspaceCreationError && error.stage === 'open' - ? 'nav.workspaces.worktreeCreateOrOpenFailed' - : 'nav.workspaces.worktreeCreateFailed', - { - error: error instanceof Error ? error.message : String(error), - }, - ), - { duration: 4000 } - ); - } - }, [openWorkspace, t, workspace.rootPath]); - const handleRequestDeleteWorktree = useCallback(() => { setMenuOpen(false); setDeleteWorktreeDialogOpen(true); @@ -920,6 +891,35 @@ const WorkspaceItem: React.FC = ({ {t('nav.workspaces.actions.newSession')} + + )} + {!isLinkedWorktree ? ( + + ) : null} + ), + Checkbox: ({ + checked, + disabled, + label, + description, + onChange, + }: { + checked: boolean; + disabled?: boolean; + label: React.ReactNode; + description?: React.ReactNode; + onChange: React.ChangeEventHandler; + }) => ( + + ), + Input: (props: React.InputHTMLAttributes) => , + Modal: ({ + children, + isOpen, + title, + }: { + children: React.ReactNode; + isOpen: boolean; + title: React.ReactNode; + }) => isOpen ?

{title}

{children}
: null, + Select: ({ + id, + value, + disabled, + options, + onChange, + }: { + id?: string; + value: string; + disabled?: boolean; + options: Array<{ value: string; label: string }>; + onChange: (value: string) => void; + }) => ( + + ), +})); + +async function flushLauncherProbe(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + vi.advanceTimersByTime(200); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('WorktreeLauncherModal', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mocks.getRepositoryBasic.mockResolvedValue({ current_branch: 'main' }); + mocks.getStatus.mockResolvedValue({ + staged: ['src/staged.ts'], + unstaged: ['src/unstaged.ts'], + untracked: ['notes.txt'], + conflicts: [], + }); + mocks.getConfig.mockResolvedValue({ + rootPath: '/managed', + branchPrefix: 'bitfun/', + defaultTarget: 'local', + copyLocalChanges: true, + }); + mocks.resolveRevision.mockResolvedValue('0123456789abcdef'); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('resolves the base and preserves the opt-in copy default only at source HEAD', async () => { + const onSubmit = vi.fn(async () => undefined); + await act(async () => { + root.render( + + ); + }); + await flushLauncherProbe(); + + const copy = container.querySelector('[data-testid="copy-local-changes"]'); + expect(copy?.disabled).toBe(false); + expect(copy?.checked).toBe(true); + expect(container.textContent).toContain('resolvedCommit'); + expect(container.textContent).toContain('/managed/Repo/…'); + + const createButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('launcher.create')); + await act(async () => { + createButton?.click(); + await Promise.resolve(); + }); + expect(onSubmit).toHaveBeenCalledWith({ + mode: 'agentic', + baseRef: 'main', + copyLocalChanges: true, + }); + }); + + it('shows a clear unsupported state without probing a remote repository', async () => { + await act(async () => { + root.render( + undefined)} + /> + ); + }); + + expect(container.textContent).toContain('launcher.remoteUnsupported'); + expect(mocks.getRepositoryBasic).not.toHaveBeenCalled(); + const createButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('launcher.create')); + expect(createButton?.disabled).toBe(true); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.tsx new file mode 100644 index 0000000000..4bc180acd6 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.tsx @@ -0,0 +1,320 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { GitBranch, Loader2 } from 'lucide-react'; +import { Button, Checkbox, Input, Modal, Select } from '@/component-library'; +import { configAPI, gitAPI } from '@/infrastructure/api'; +import { useI18n } from '@/infrastructure/i18n'; +import type { GitStatus } from '@/infrastructure/api/service-api/GitAPI'; +import './WorktreeLauncherModal.scss'; + +export type WorktreeSessionMode = 'agentic' | 'Cowork'; + +export interface WorktreeLauncherSubmit { + mode: WorktreeSessionMode; + baseRef: string; + copyLocalChanges: boolean; +} + +interface WorktreeSettings { + defaultTarget: 'local' | 'managedWorktree'; + rootPath: string; + branchPrefix: string; + copyLocalChanges: boolean; +} + +interface WorktreeLauncherModalProps { + isOpen: boolean; + projectWorkspacePath: string; + projectName: string; + remote?: boolean; + initialMode?: WorktreeSessionMode; + onClose: () => void; + onSubmit: (request: WorktreeLauncherSubmit) => Promise; +} + +const DEFAULT_SETTINGS: WorktreeSettings = { + defaultTarget: 'local', + rootPath: '~/.bitfun/worktrees', + branchPrefix: 'bitfun/', + copyLocalChanges: false, +}; + +function changeCount(status: GitStatus | null): number { + if (!status) return 0; + return ( + status.staged.length + + status.unstaged.length + + status.untracked.length + + status.conflicts.length + ); +} + +export const WorktreeLauncherModal: React.FC = ({ + isOpen, + projectWorkspacePath, + projectName, + remote = false, + initialMode = 'agentic', + onClose, + onSubmit, +}) => { + const { t } = useI18n('worktrees'); + const [mode, setMode] = useState(initialMode); + const [baseRef, setBaseRef] = useState('HEAD'); + const [baseCommit, setBaseCommit] = useState(''); + const [sourceHead, setSourceHead] = useState(''); + const [status, setStatus] = useState(null); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [copyLocalChanges, setCopyLocalChanges] = useState(false); + const [loading, setLoading] = useState(false); + const [probing, setProbing] = useState(false); + const [error, setError] = useState(null); + const [availabilityError, setAvailabilityError] = useState(null); + + useEffect(() => { + if (!isOpen) return; + setMode(initialMode); + setError(null); + setSourceHead(''); + setAvailabilityError(remote ? t('launcher.remoteUnsupported') : null); + setProbing(!remote); + let cancelled = false; + + if (remote) { + setStatus(null); + setBaseCommit(''); + return; + } + + void Promise.all([ + gitAPI.getRepositoryBasic(projectWorkspacePath), + gitAPI.getStatus(projectWorkspacePath, 'worktree_launcher'), + configAPI.getConfig('app.worktrees', { skipRetryOnNotFound: true }), + gitAPI.resolveRevision(projectWorkspacePath, 'HEAD').catch(() => ''), + ]) + .then(([repository, nextStatus, configured, headCommit]) => { + if (cancelled) return; + const nextSettings = { + ...DEFAULT_SETTINGS, + ...(configured && typeof configured === 'object' ? configured : {}), + } as WorktreeSettings; + const suggestedRef = repository.current_branch?.trim() || 'HEAD'; + setSettings(nextSettings); + setBaseRef(suggestedRef); + setSourceHead(headCommit); + setStatus(nextStatus); + setCopyLocalChanges( + nextSettings.copyLocalChanges + && changeCount(nextStatus) > 0 + && !!headCommit, + ); + if (!headCommit) { + setAvailabilityError(t('launcher.unbornRepository')); + } + }) + .catch(() => { + if (!cancelled) { + setAvailabilityError(t('launcher.notGitRepository')); + setStatus(null); + } + }) + .finally(() => { + if (!cancelled) setProbing(false); + }); + + return () => { + cancelled = true; + }; + }, [initialMode, isOpen, projectWorkspacePath, remote, t]); + + useEffect(() => { + if (!isOpen || remote || availabilityError || !baseRef.trim()) { + setBaseCommit(''); + return; + } + let cancelled = false; + const timer = window.setTimeout(() => { + void gitAPI + .resolveRevision(projectWorkspacePath, baseRef.trim()) + .then(commit => { + if (!cancelled) { + setBaseCommit(commit); + setError(null); + } + }) + .catch(resolveError => { + if (!cancelled) { + setBaseCommit(''); + const message = resolveError instanceof Error + ? resolveError.message.toLowerCase() + : String(resolveError).toLowerCase(); + if ( + baseRef.trim() === 'HEAD' + && ( + message.includes('unborn') + || message.includes('initial commit') + || message.includes('unknown revision') + || message.includes('needed a single revision') + ) + ) { + setAvailabilityError(t('launcher.unbornRepository')); + setError(null); + } else { + setError(t('launcher.invalidBaseRef')); + } + } + }); + }, 180); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [availabilityError, baseRef, isOpen, projectWorkspacePath, remote, t]); + + const dirtyCount = changeCount(status); + const canCopyLocalChanges = + dirtyCount > 0 && !!sourceHead && baseCommit === sourceHead; + useEffect(() => { + if (!canCopyLocalChanges) { + setCopyLocalChanges(false); + } else if (settings.copyLocalChanges) { + setCopyLocalChanges(true); + } + }, [canCopyLocalChanges, settings.copyLocalChanges]); + const targetPreview = useMemo( + () => `${settings.rootPath.replace(/\/$/, '')}/${projectName}/…`, + [projectName, settings.rootPath], + ); + const canSubmit = !probing && !availabilityError && !!baseCommit && !loading; + + const submit = async () => { + if (!canSubmit) return; + setLoading(true); + setError(null); + try { + await onSubmit({ + mode, + baseRef: baseRef.trim(), + copyLocalChanges: copyLocalChanges && dirtyCount > 0, + }); + onClose(); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : String(submitError)); + } finally { + setLoading(false); + } + }; + + return ( + +
{ + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + void submit(); + } + }} + > +

{t('launcher.description')}

+ +
+ + setBaseRef(event.target.value)} + placeholder="HEAD" + disabled={loading || probing || !!availabilityError} + autoFocus + /> + + {baseCommit + ? t('launcher.resolvedCommit', { commit: baseCommit.slice(0, 12) }) + : t('launcher.baseRefHint')} + +
+ +
+ {t('launcher.targetPath')} + {targetPreview} +
+ + {dirtyCount > 0 ? ( +
+ setCopyLocalChanges(event.target.checked)} + disabled={loading || !canCopyLocalChanges} + label={t('launcher.copyChanges')} + description={ + t('launcher.copyChangesSummary', { + count: dirtyCount, + staged: status?.staged.length ?? 0, + unstaged: status?.unstaged.length ?? 0, + untracked: status?.untracked.length ?? 0, + }) + + ( + canCopyLocalChanges + ? '' + : ` ${t('launcher.copyChangesRequiresHead')}` + ) + } + /> +
+ ) : null} + + {probing ? ( +
+ + {t('launcher.checking')} +
+ ) : null} + {availabilityError || error ? ( +
+ {availabilityError || error} +
+ ) : null} + +
+ + +
+
+
+ ); +}; + +export default WorktreeLauncherModal; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.scss new file mode 100644 index 0000000000..a68866a05d --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.scss @@ -0,0 +1,120 @@ +.bitfun-worktree-manager { + display: flex; + min-height: 320px; + max-height: min(70vh, 720px); + flex-direction: column; + gap: 14px; + padding: 18px; +} + +.bitfun-worktree-manager__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.bitfun-worktree-manager__toolbar > p { + margin: 0; + color: var(--color-text-secondary); + font-size: 12px; +} + +.bitfun-worktree-manager__toolbar > div, +.bitfun-worktree-manager__actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.bitfun-worktree-manager__state, +.bitfun-worktree-manager__error, +.bitfun-worktree-manager__empty { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px; + border: 1px dashed var(--color-border); + border-radius: 8px; + color: var(--color-text-secondary); + font-size: 12px; +} + +.bitfun-worktree-manager__error { + color: var(--color-error); +} + +.bitfun-worktree-manager__empty { + flex-direction: column; + min-height: 180px; +} + +.bitfun-worktree-manager__list { + display: flex; + overflow: auto; + flex-direction: column; + gap: 8px; +} + +.bitfun-worktree-manager__item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-secondary); +} + +.bitfun-worktree-manager__item-copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 5px; +} + +.bitfun-worktree-manager__item-heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.bitfun-worktree-manager__item-heading > span { + padding: 1px 5px; + border-radius: 9px; + background: var(--color-bg-tertiary); + color: var(--color-text-secondary); + font-size: 10px; +} + +.bitfun-worktree-manager__item-copy code, +.bitfun-worktree-manager__item-copy > span { + overflow: hidden; + color: var(--color-text-secondary); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bitfun-worktree-manager__risk-list p { + margin: 0 0 8px; +} + +.bitfun-worktree-manager__risk-list ul { + margin: 0; + padding-left: 18px; +} + +.bitfun-worktree-manager .is-spinning { + animation: bitfun-worktree-manager-spin 0.8s linear infinite; +} + +@keyframes bitfun-worktree-manager-spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.test.tsx new file mode 100644 index 0000000000..693b1ae83b --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.test.tsx @@ -0,0 +1,217 @@ +/** + * @vitest-environment jsdom + */ + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { WorktreeSummary } from '@/infrastructure/api/service-api/WorktreeAPI'; +import { WorktreeManagerModal } from './WorktreeManagerModal'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + getConfig: vi.fn(), + remove: vi.fn(), + refresh: vi.fn(async () => undefined), + revealInExplorer: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})); + +vi.mock('@/infrastructure/api', () => ({ + configAPI: { getConfig: mocks.getConfig }, + workspaceAPI: { revealInExplorer: mocks.revealInExplorer }, + worktreeAPI: { + createBranch: vi.fn(), + promote: vi.fn(), + recreate: vi.fn(), + remove: mocks.remove, + }, +})); + +vi.mock('@/infrastructure/api/service-api/WorktreeAPI', () => ({ + WorktreeCommandError: class WorktreeCommandError extends Error { + constructor( + public readonly code: string, + message: string, + public readonly recoveryPath?: string, + ) { + super(message); + } + }, +})); + +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ + t: (key: string, values?: Record) => + values ? `${key}:${JSON.stringify(values)}` : key, + }), +})); + +vi.mock('@/shared/notification-system', () => ({ + notificationService: { + success: mocks.success, + error: mocks.error, + }, +})); + +vi.mock('@/component-library', () => ({ + Button: ({ + children, + disabled, + onClick, + }: { + children: React.ReactNode; + disabled?: boolean; + onClick?: () => void; + }) => ( + + ), + ConfirmDialog: ({ + isOpen, + title, + message, + preview, + confirmText, + onConfirm, + }: { + isOpen: boolean; + title: React.ReactNode; + message: React.ReactNode; + preview?: React.ReactNode; + confirmText: React.ReactNode; + onConfirm: () => void; + }) => isOpen ? ( +
+

{title}

+ {message} + {preview} + +
+ ) : null, + InputDialog: () => null, + Modal: ({ + children, + isOpen, + title, + }: { + children: React.ReactNode; + isOpen: boolean; + title: React.ReactNode; + }) => isOpen ?

{title}

{children}
: null, +})); + +function summary(overrides: Partial = {}): WorktreeSummary { + return { + worktreeId: 'wt-1', + projectWorkspacePath: '/repo', + path: '/managed/wt-1', + head: '0123456789abcdef', + lifecycle: 'managed', + isMain: false, + dirty: false, + locked: false, + missing: false, + hasUnpublishedCommits: false, + associatedSessionCount: 0, + runningSessionCount: 0, + sessions: [], + ...overrides, + }; +} + +describe('WorktreeManagerModal removal safety', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mocks.getConfig.mockResolvedValue({ branchPrefix: 'bitfun/' }); + mocks.remove.mockResolvedValue({ worktreeId: 'wt-1', removed: true }); + mocks.refresh.mockClear(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + }); + + async function openRemoveDialog(worktree: WorktreeSummary): Promise { + await act(async () => { + root.render( + undefined)} + /> + ); + await Promise.resolve(); + }); + const item = container.querySelector('[data-worktree-id="wt-1"]'); + const removeButton = Array.from(item?.querySelectorAll('button') ?? []) + .find(button => button.textContent?.includes('manager.remove')); + await act(async () => { + removeButton?.click(); + }); + } + + it('lists each loss risk and requires a second confirmation before force removal', async () => { + await openRemoveDialog(summary({ + dirty: true, + hasUnpublishedCommits: true, + associatedSessionCount: 2, + })); + + expect(container.textContent).toContain('manager.risks.dirty'); + expect(container.textContent).toContain('manager.risks.unpublished'); + expect(container.textContent).toContain('manager.risks.sessions'); + + await act(async () => { + container.querySelector('[data-testid="confirm-remove"]')?.click(); + }); + expect(mocks.remove).not.toHaveBeenCalled(); + expect(container.textContent).toContain('manager.removeDialog.forceTitle'); + + await act(async () => { + container.querySelector('[data-testid="confirm-remove"]')?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mocks.remove).toHaveBeenCalledWith( + '/repo', + 'wt-1', + expect.any(String), + true, + ); + expect(mocks.refresh).toHaveBeenCalledOnce(); + }); + + it('never offers force removal while a session remains unarchived', async () => { + await openRemoveDialog(summary({ + associatedSessionCount: 1, + runningSessionCount: 1, + })); + + expect(container.textContent).toContain('manager.removeDialog.blocked'); + expect(container.textContent).toContain('manager.risks.running'); + await act(async () => { + container.querySelector('[data-testid="confirm-remove"]')?.click(); + }); + expect(mocks.remove).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="confirm-dialog"]')).toBeNull(); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.tsx new file mode 100644 index 0000000000..541f1e7aed --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.tsx @@ -0,0 +1,385 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Archive, + ExternalLink, + GitBranch, + Loader2, + Plus, + RefreshCw, + Trash2, +} from 'lucide-react'; +import { Button, ConfirmDialog, InputDialog, Modal } from '@/component-library'; +import { configAPI, workspaceAPI, worktreeAPI } from '@/infrastructure/api'; +import type { WorktreeSummary } from '@/infrastructure/api/service-api/WorktreeAPI'; +import { WorktreeCommandError } from '@/infrastructure/api/service-api/WorktreeAPI'; +import { useI18n } from '@/infrastructure/i18n'; +import { notificationService } from '@/shared/notification-system'; +import './WorktreeManagerModal.scss'; + +interface WorktreeManagerModalProps { + isOpen: boolean; + projectWorkspacePath: string; + worktrees: WorktreeSummary[]; + loading: boolean; + error?: string | null; + onClose: () => void; + onRefresh: () => Promise; + onCreateWorktree: () => void; + onCreateSession: (worktree: WorktreeSummary) => Promise; +} + +function requestId(): string { + return globalThis.crypto?.randomUUID?.() ?? `worktree-${Date.now()}-${Math.random()}`; +} + +export const WorktreeManagerModal: React.FC = ({ + isOpen, + projectWorkspacePath, + worktrees, + loading, + error, + onClose, + onRefresh, + onCreateWorktree, + onCreateSession, +}) => { + const { t } = useI18n('worktrees'); + const [branchTarget, setBranchTarget] = useState(null); + const [removeTarget, setRemoveTarget] = useState(null); + const [forceStage, setForceStage] = useState(false); + const [pendingId, setPendingId] = useState(null); + const [branchPrefix, setBranchPrefix] = useState('bitfun/'); + + useEffect(() => { + if (!isOpen) return; + void configAPI + .getConfig('app.worktrees', { skipRetryOnNotFound: true }) + .then(value => { + if (value && typeof value.branchPrefix === 'string') { + setBranchPrefix(value.branchPrefix); + } + }) + .catch(() => undefined); + }, [isOpen]); + + const visibleWorktrees = useMemo( + () => worktrees.filter(worktree => !worktree.isMain), + [worktrees], + ); + const lifecycleLabel = (worktree: WorktreeSummary): string => { + if (worktree.lifecycle === 'permanent') return t('labels.lifecycle.permanent'); + if (worktree.lifecycle === 'external') return t('labels.lifecycle.external'); + return t('labels.lifecycle.managed'); + }; + + const runMutation = async ( + worktree: WorktreeSummary, + operation: () => Promise, + successMessage: string, + ): Promise => { + setPendingId(worktree.worktreeId); + try { + await operation(); + notificationService.success(successMessage, { duration: 2500 }); + await onRefresh(); + return null; + } catch (operationError) { + notificationService.error( + operationError instanceof Error ? operationError.message : String(operationError), + { duration: 4500 }, + ); + return operationError; + } finally { + setPendingId(null); + } + }; + + const confirmRemove = async () => { + if (!removeTarget) return; + const hasBlockingSessions = removeTarget.runningSessionCount > 0; + if (hasBlockingSessions) { + setRemoveTarget(null); + setForceStage(false); + return; + } + const needsForce = removeTarget.dirty || removeTarget.hasUnpublishedCommits; + if (needsForce && !forceStage) { + setForceStage(true); + return; + } + const target = removeTarget; + const operationError = await runMutation( + target, + () => worktreeAPI.remove( + projectWorkspacePath, + target.worktreeId, + requestId(), + needsForce, + ), + t('manager.removed'), + ); + if (!operationError) { + setRemoveTarget(null); + setForceStage(false); + } else if ( + operationError instanceof WorktreeCommandError + && (operationError.code === 'dirty_worktree' + || operationError.code === 'unpublished_commits') + ) { + setForceStage(true); + } + }; + + const removeRisks = removeTarget + ? [ + removeTarget.dirty ? t('manager.risks.dirty') : null, + removeTarget.hasUnpublishedCommits ? t('manager.risks.unpublished') : null, + removeTarget.associatedSessionCount > 0 + ? t('manager.risks.sessions', { count: removeTarget.associatedSessionCount }) + : null, + removeTarget.runningSessionCount > 0 + ? t('manager.risks.running', { count: removeTarget.runningSessionCount }) + : null, + ].filter((value): value is string => !!value) + : []; + + return ( + <> + +
+
+

{t('manager.description')}

+
+ + +
+
+ + {loading ? ( +
+ + {t('manager.loading')} +
+ ) : null} + {error ? ( +
{error}
+ ) : null} + {!loading && !error && visibleWorktrees.length === 0 ? ( +
+ + {t('manager.emptyTitle')} + {t('manager.emptyDescription')} +
+ ) : null} + +
+ {visibleWorktrees.map(worktree => { + const pending = pendingId === worktree.worktreeId; + const revision = worktree.branch || t('labels.detached', { + commit: worktree.head.slice(0, 10), + }); + return ( +
+
+
+ + {revision} + {lifecycleLabel(worktree)} + {worktree.dirty ? {t('labels.dirty')} : null} + {worktree.missing ? {t('labels.missing')} : null} +
+ {worktree.path} + + {t('manager.sessionCount', { count: worktree.associatedSessionCount })} + {worktree.hasUnpublishedCommits + ? ` · ${t('labels.unpublished')}` + : ''} + +
+
+ {!worktree.missing ? ( + <> + + + {!worktree.branch ? ( + + ) : null} + + ) : ( + + )} + {worktree.lifecycle === 'managed' ? ( + + ) : null} + +
+
+ ); + })} +
+
+
+ + setBranchTarget(null)} + onConfirm={branch => { + const target = branchTarget; + if (!target) return; + void runMutation( + target, + () => worktreeAPI.createBranch( + projectWorkspacePath, + target.worktreeId, + branch, + requestId(), + ), + t('manager.branchCreated'), + ); + }} + title={t('manager.branchDialog.title')} + description={t('manager.branchDialog.description')} + defaultValue={`${branchPrefix}${branchTarget?.worktreeId.slice(0, 8) ?? ''}`} + confirmText={t('manager.createBranch')} + validator={value => value.trim() ? null : t('manager.branchDialog.required')} + /> + + { + setRemoveTarget(null); + setForceStage(false); + }} + onConfirm={() => void confirmRemove()} + title={ + forceStage + ? t('manager.removeDialog.forceTitle') + : t('manager.removeDialog.title') + } + type={forceStage ? 'error' : 'warning'} + message={ +
+

+ {removeTarget?.runningSessionCount + ? t('manager.removeDialog.blocked') + : forceStage + ? t('manager.removeDialog.forceMessage') + : t('manager.removeDialog.message')} +

+ {removeRisks.length > 0 ? ( +
    + {removeRisks.map(risk =>
  • {risk}
  • )} +
+ ) : ( + {t('manager.risks.clean')} + )} +
+ } + preview={removeTarget?.path} + confirmText={ + removeTarget?.runningSessionCount + ? t('actions.close') + : forceStage + ? t('manager.removeDialog.forceConfirm') + : t('manager.remove') + } + cancelText={t('actions.cancel')} + confirmDanger={!removeTarget?.runningSessionCount} + showCancel={!removeTarget?.runningSessionCount} + /> + + ); +}; + +export default WorktreeManagerModal; diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index dfb1fc71fb..c17097392c 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -32,6 +32,7 @@ import { SessionPermissionsConfig, SessionPersonalizationConfig, VoiceInputConfig, + WorktreesConfig, } from './settingsContentRegistry'; import './SettingsScene.scss'; @@ -55,6 +56,7 @@ function resolveSettingsContent(tab: ConfigTab): React.ComponentType | null { case 'appearance': return AppearanceConfig; case 'models': return AIModelConfig; case 'archived-sessions': return ArchivedSessionsConfig; + case 'worktrees': return WorktreesConfig; case 'session-personalization': return SessionPersonalizationConfig; case 'session-permissions': return SessionPermissionsConfig; case 'quick-actions': return QuickActionsConfig; diff --git a/src/web-ui/src/app/scenes/settings/settingsConfig.ts b/src/web-ui/src/app/scenes/settings/settingsConfig.ts index dea5145ee5..5d8d084b8e 100644 --- a/src/web-ui/src/app/scenes/settings/settingsConfig.ts +++ b/src/web-ui/src/app/scenes/settings/settingsConfig.ts @@ -9,6 +9,7 @@ export type ConfigTab = | 'basics' | 'appearance' | 'models' + | 'worktrees' | 'archived-sessions' | 'session-personalization' | 'session-permissions' @@ -116,6 +117,19 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [ 'unarchive', ], }, + { + id: 'worktrees', + labelKey: 'configCenter.tabs.worktrees', + descriptionKey: 'configCenter.tabDescriptions.worktrees', + keywords: [ + 'git', + 'worktree', + 'isolation', + 'parallel', + 'branch', + 'session', + ], + }, { id: 'keyboard', labelKey: 'configCenter.tabs.keyboard', diff --git a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts index 19348b6700..dc000f35cb 100644 --- a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts @@ -14,6 +14,7 @@ const loadMemoriesConfig = () => import('../../../infrastructure/config/componen const loadQuickActionsConfig = () => import('../../../infrastructure/config/components/QuickActionsConfig'); const loadVoiceInputConfig = () => import('../../../infrastructure/config/components/VoiceInputConfig'); const loadArchivedSessionsConfig = () => import('./components/ArchivedSessionsConfig'); +const loadWorktreesConfig = () => import('../../../infrastructure/config/components/WorktreesConfig'); const loadKeyboardShortcutsTab = () => import('./components/KeyboardShortcutsTab'); const loadSessionConfig = () => import('../../../infrastructure/config/components/SessionConfig'); @@ -30,6 +31,7 @@ export const MemoriesConfig = lazy(loadMemoriesConfig); export const QuickActionsConfig = lazy(loadQuickActionsConfig); export const VoiceInputConfig = lazy(loadVoiceInputConfig); export const ArchivedSessionsConfig = lazy(loadArchivedSessionsConfig); +export const WorktreesConfig = lazy(loadWorktreesConfig); export const KeyboardShortcutsTab = lazy(loadKeyboardShortcutsTab); export const SessionPersonalizationConfig = lazy(() => loadSessionConfig().then((module) => ({ @@ -47,6 +49,7 @@ const SETTINGS_CONTENT_LOADERS: Partial Promise appearance: loadAppearanceConfig, models: loadAIModelConfig, 'archived-sessions': loadArchivedSessionsConfig, + worktrees: loadWorktreesConfig, 'session-personalization': loadSessionConfig, 'session-permissions': loadSessionConfig, 'quick-actions': loadQuickActionsConfig, diff --git a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts index fbddf85cc9..0577c3b79b 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts @@ -49,6 +49,16 @@ export const SETTINGS_TAB_SEARCH_CONTENT: Record = ({ const workspaceName = hasRegisteredWorkspace ? (workspacePath ? path.basename(workspacePath) : '') : currentWorkspaceName; - const workspacePathRef = useRef(workspacePath || ''); - workspacePathRef.current = workspacePath || ''; + const sessionBoundWorkspacePath = ( + (!hasRegisteredWorkspace && effectiveTargetSession?.workspacePath) + || workspacePath + || '' + ).trim(); + const workspacePathRef = useRef(sessionBoundWorkspacePath); + workspacePathRef.current = sessionBoundWorkspacePath; const { openedWorkspaces } = useWorkspaceContext(); const chatStripRepositoryPath = useMemo(() => { + const fromSession = hasRegisteredWorkspace + ? '' + : (effectiveTargetSession?.workspacePath || '').trim(); const fromContext = (workspacePath || '').trim(); - const fromSession = (effectiveTargetSession?.workspacePath || '').trim(); - return fromContext || fromSession; - }, [workspacePath, effectiveTargetSession?.workspacePath]); + return fromSession || fromContext; + }, [hasRegisteredWorkspace, workspacePath, effectiveTargetSession?.workspacePath]); const chatStripWorkspaceLabel = useMemo(() => { const name = (workspaceName || '').trim(); - if (name) return name; + const sessionPath = hasRegisteredWorkspace + ? '' + : (effectiveTargetSession?.workspacePath || '').trim(); + const contextPath = (workspacePath || '').trim(); + const sessionUsesDifferentRoot = !!sessionPath + && (!contextPath || !isSamePath(sessionPath, contextPath)); + if (name && !sessionUsesDifferentRoot) return name; if (chatStripRepositoryPath) return path.basename(chatStripRepositoryPath); return ''; - }, [workspaceName, chatStripRepositoryPath]); + }, [ + chatStripRepositoryPath, + effectiveTargetSession?.workspacePath, + hasRegisteredWorkspace, + workspaceName, + workspacePath, + ]); const [tokenUsage, setTokenUsage] = React.useState( getSessionContextUsageDisplay() @@ -1064,7 +1084,7 @@ export const ChatInput: React.FC = ({ acpTargetAgentType, composerMode: currentMode, }); - const targetWorkspacePath = (workspacePath || effectiveTargetSession?.workspacePath || '').trim(); + const targetWorkspacePath = sessionBoundWorkspacePath; useEffect(() => { if (!isSubagentInputTarget) { @@ -1235,7 +1255,7 @@ export const ChatInput: React.FC = ({ } try { const snapshot = await externalSourcesAPI.getSnapshot( - workspacePath || undefined, + sessionBoundWorkspacePath || undefined, forceRefresh, ); if (requestId !== externalPromptCatalogRequestRef.current) return undefined; @@ -1265,7 +1285,7 @@ export const ChatInput: React.FC = ({ setExternalPromptCommandsLoading(false); } } - }, [isAcpInputSession, workspacePath]); + }, [isAcpInputSession, sessionBoundWorkspacePath]); useEffect(() => { externalPromptCatalogRequestRef.current += 1; @@ -2256,7 +2276,7 @@ export const ChatInput: React.FC = ({ }, [addContext, currentImageCount, dispatchInput, inputState.isActive, t]); React.useEffect(() => { - if (!effectiveTargetSessionId || !workspacePath) { + if (!effectiveTargetSessionId || !sessionBoundWorkspacePath) { return; } @@ -2274,20 +2294,20 @@ export const ChatInput: React.FC = ({ const modifiedFiles = collectModifiedFilePathsFromTurns( [lastTurn], undefined, - workspacePath, + sessionBoundWorkspacePath, ); if (modifiedFiles.length > 0) { log.debug('File modifications detected, updating recommendation context', { modifiedFiles }); setRecommendationContext({ - workspacePath, + workspacePath: sessionBoundWorkspacePath, sessionId: effectiveTargetSessionId, turnIndex: lastTurn.backendTurnIndex ?? session.dialogTurns.length - 1, modifiedFiles, }); } } - }, [effectiveTargetSessionId, workspacePath, derivedState?.isProcessing]); + }, [effectiveTargetSessionId, sessionBoundWorkspacePath, derivedState?.isProcessing]); const getFilteredActions = useCallback(() => { if (isAcpInputSession) { @@ -2673,7 +2693,7 @@ export const ChatInput: React.FC = ({ const { childSessionId } = await startBtwThread({ parentSessionId: currentSessionId, - workspacePath, + workspacePath: sessionBoundWorkspacePath, question, imagePayload, }); @@ -2681,7 +2701,7 @@ export const ChatInput: React.FC = ({ openBtwSessionInAuxPane({ childSessionId, parentSessionId: currentSessionId, - workspacePath, + workspacePath: sessionBoundWorkspacePath, expand: true, }); setInputTarget('btw'); @@ -2692,7 +2712,7 @@ export const ChatInput: React.FC = ({ replacePendingLargePastes(originalPendingLargePastes); dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); } - }, [clearPendingLargePastes, currentSessionId, derivedState, dispatchInput, expandComposerSpecialTokens, imageContexts, inputState.value, isBtwSession, removeContext, replacePendingLargePastes, setQueuedInput, t, workspacePath]); + }, [clearPendingLargePastes, currentSessionId, derivedState, dispatchInput, expandComposerSpecialTokens, imageContexts, inputState.value, isBtwSession, removeContext, replacePendingLargePastes, sessionBoundWorkspacePath, setQueuedInput, t]); const submitCompactFromInput = useCallback(async () => { if (!effectiveTargetSessionId || !effectiveTargetSession) { @@ -2970,7 +2990,7 @@ export const ChatInput: React.FC = ({ // to user + built-in slots only and the toast would undercount. const skills = await configAPI.getSkillConfigs({ forceRefresh: true, - workspacePath: workspacePath || undefined, + workspacePath: sessionBoundWorkspacePath || undefined, }); notificationService.success( t('chatInput.reloadSkillsDone', { count: skills.length }), @@ -2988,7 +3008,7 @@ export const ChatInput: React.FC = ({ } ); } - }, [dispatchInput, inputState.value, setQueuedInput, t, workspacePath]); + }, [dispatchInput, inputState.value, sessionBoundWorkspacePath, setQueuedInput, t]); const submitReviewFromInput = useCallback(async () => { if (!canLaunchReview) { @@ -3217,7 +3237,7 @@ export const ChatInput: React.FC = ({ originalPendingLargePastes: PendingLargePasteMap, ): Promise => { const submissionSessionId = effectiveTargetSessionId; - const submissionWorkspacePath = workspacePath || ''; + const submissionWorkspacePath = sessionBoundWorkspacePath; const submissionComposerValue = inputValueRef.current; const submissionTargetIsCurrent = () => isExternalPromptSubmissionTargetCurrent( submissionSessionId, @@ -3481,7 +3501,7 @@ export const ChatInput: React.FC = ({ ); } return true; - }, [addToHistory, clearPendingLargePastes, confirmPromptCacheGuardIfNeeded, dispatchInput, effectiveTargetSessionId, externalPromptCommands, externalPromptCommandsIssue, externalPromptCommandsLoading, externalPromptCommandsPending, getSlashPickerItems, refreshExternalPromptCommands, replacePendingLargePastes, selectedExternalPromptCandidateId, selectedNonExternalSlashCandidateId, selectedNonExternalSlashCommand, sendMessage, setQueuedInput, t, workspacePath]); + }, [addToHistory, clearPendingLargePastes, confirmPromptCacheGuardIfNeeded, dispatchInput, effectiveTargetSessionId, externalPromptCommands, externalPromptCommandsIssue, externalPromptCommandsLoading, externalPromptCommandsPending, getSlashPickerItems, refreshExternalPromptCommands, replacePendingLargePastes, selectedExternalPromptCandidateId, selectedNonExternalSlashCandidateId, selectedNonExternalSlashCommand, sendMessage, sessionBoundWorkspacePath, setQueuedInput, t]); const handleCancelCurrentTask = useCallback(async () => { if (effectiveTargetSessionId) { @@ -3801,7 +3821,7 @@ export const ChatInput: React.FC = ({ operationIsCurrent: () => boolean, ): Promise => { const capturedSessionId = effectiveTargetSessionId; - const capturedWorkspacePath = workspacePath || ''; + const capturedWorkspacePath = sessionBoundWorkspacePath; const targetIsCurrent = () => isExternalPromptSubmissionTargetCurrent( capturedSessionId, effectiveTargetSessionIdRef.current, @@ -3842,7 +3862,7 @@ export const ChatInput: React.FC = ({ } } return targetIsCurrent() && operationIsCurrent(); - }, [effectiveTargetSessionId, externalPromptCommandsIssue, t, workspacePath]); + }, [effectiveTargetSessionId, externalPromptCommandsIssue, sessionBoundWorkspacePath, t]); const selectSlashCommandMode = useCallback((modeId: string) => { // Same gating as the mode dropdown; slash commands must not bypass it. @@ -4735,7 +4755,7 @@ export const ChatInput: React.FC = ({ { addContext(context); @@ -5267,6 +5287,11 @@ export const ChatInput: React.FC = ({ button { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 8px; + border: 0; + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-secondary); + font: inherit; + text-align: left; + cursor: pointer; + + &:hover:not(:disabled), + &:focus-visible { + background: var(--element-bg-medium); + color: var(--color-text-primary); + } + + &:disabled { + cursor: wait; + opacity: 0.55; + } } } diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx index 66b7f8a01a..8d14bf4867 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx @@ -9,10 +9,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatInputWorkspaceStrip } from './ChatInputWorkspaceStrip'; const mocks = vi.hoisted(() => ({ + refreshBasic: vi.fn(async () => undefined), useGitState: vi.fn(() => ({ currentBranch: 'main', isRepository: true, + refreshBasic: vi.fn(async () => undefined), })), + listWorktrees: vi.fn(), + onWorktreeChanged: vi.fn(), })); vi.mock('react-i18next', () => ({ @@ -29,6 +33,9 @@ vi.mock('@/component-library', () => ({ IconButton: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( ), + InputDialog: ({ isOpen }: { isOpen: boolean }) => ( + isOpen ?
: null + ), Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, })); @@ -36,6 +43,17 @@ vi.mock('@/tools/git/hooks/useGitState', () => ({ useGitState: mocks.useGitState, })); +vi.mock('@/infrastructure/api', () => ({ + configAPI: { getConfig: vi.fn() }, + workspaceAPI: { revealInExplorer: vi.fn() }, + worktreeAPI: { + list: mocks.listWorktrees, + onChanged: mocks.onWorktreeChanged, + createBranch: vi.fn(), + promote: vi.fn(), + }, +})); + describe('ChatInputWorkspaceStrip git refresh behavior', () => { let container: HTMLDivElement; let root: Root; @@ -48,7 +66,11 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { mocks.useGitState.mockReturnValue({ currentBranch: 'main', isRepository: true, + refreshBasic: mocks.refreshBasic, }); + mocks.listWorktrees.mockReset(); + mocks.onWorktreeChanged.mockReset(); + mocks.onWorktreeChanged.mockReturnValue(vi.fn()); }); afterEach(() => { @@ -156,4 +178,56 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { expect(trigger?.dataset.permissionMode).toBe('acp'); expect(container.querySelector('[data-testid="chat-input-permission-menu"]')).toBeNull(); }); + + it('refreshes the session-bound worktree chip from worktree events', async () => { + let onChanged: ((event: { projectWorkspacePath: string }) => void) | undefined; + mocks.onWorktreeChanged.mockImplementation(callback => { + onChanged = callback; + return vi.fn(); + }); + mocks.listWorktrees.mockResolvedValue([{ + worktreeId: 'wt-1', + projectWorkspacePath: '/repo', + path: '/worktrees/wt-1', + head: '0123456789abcdef', + branch: 'bitfun/isolated', + lifecycle: 'permanent', + isMain: false, + dirty: false, + locked: false, + missing: false, + hasUnpublishedCommits: false, + associatedSessionCount: 1, + runningSessionCount: 1, + sessions: [], + }]); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + + expect(mocks.listWorktrees).toHaveBeenCalledWith('/repo'); + expect(container.textContent).toContain('bitfun/isolated'); + + await act(async () => { + onChanged?.({ projectWorkspacePath: '/repo' }); + await Promise.resolve(); + }); + expect(mocks.listWorktrees).toHaveBeenCalledTimes(2); + expect(mocks.refreshBasic).toHaveBeenCalled(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index 3dc371ee54..693fb2d0de 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -4,11 +4,31 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Activity, Check, EyeOff, GitBranch, Shield, ShieldAlert, ShieldCheck } from 'lucide-react'; +import { + Activity, + Archive, + Check, + EyeOff, + FolderOpen, + GitBranch, + Settings2, + Shield, + ShieldAlert, + ShieldCheck, +} from 'lucide-react'; import { ThreadGoalStripButton } from './thread-goal/ThreadGoalStripButton'; import type { ThreadGoalSnapshot } from '../services/goalService'; -import { Tooltip, IconButton } from '@/component-library'; +import { Tooltip, IconButton, InputDialog } from '@/component-library'; import { useGitState } from '@/tools/git/hooks/useGitState'; +import { configAPI, workspaceAPI, worktreeAPI } from '@/infrastructure/api'; +import type { + SessionExecutionTarget, + WorktreeSummary, +} from '@/infrastructure/api/service-api/WorktreeAPI'; +import { useI18n } from '@/infrastructure/i18n'; +import { notificationService } from '@/shared/notification-system'; +import { openWorktreeManager } from '@/shared/services/worktreeUIEvents'; +import { isSamePath } from '@/shared/utils/pathUtils'; import './ChatInputWorkspaceStrip.scss'; export interface ChatInputWorkspaceStripProps { @@ -36,6 +56,10 @@ export interface ChatInputWorkspaceStripProps { }; /** Keep the strip on cached Git state while historical content is still restoring. */ deferPassiveGitRefresh?: boolean; + /** Resolved target bound to the active session. */ + executionTarget?: SessionExecutionTarget; + /** Main project that owns the active worktree session. */ + projectWorkspacePath?: string; } export type ChatInputPermissionMode = 'ask' | 'auto' | 'full_access' | 'acp'; @@ -53,14 +77,23 @@ export const ChatInputWorkspaceStrip: React.FC = ( threadGoal, permissionControl, deferPassiveGitRefresh = false, + executionTarget, + projectWorkspacePath, }) => { const { t } = useTranslation('flow-chat'); + const { t: tWorktrees } = useI18n('worktrees'); const permissionRootRef = useRef(null); + const worktreeRootRef = useRef(null); const [permissionMenuOpen, setPermissionMenuOpen] = useState(false); + const [worktreeMenuOpen, setWorktreeMenuOpen] = useState(false); + const [branchDialogOpen, setBranchDialogOpen] = useState(false); + const [branchPrefix, setBranchPrefix] = useState('bitfun/'); + const [worktreeMutationPending, setWorktreeMutationPending] = useState(false); + const [liveWorktree, setLiveWorktree] = useState(null); const trimmedPath = repositoryPath.trim(); const label = workspaceLabel.trim(); - const { currentBranch, isRepository } = useGitState({ + const { currentBranch, isRepository, refreshBasic } = useGitState({ repositoryPath: trimmedPath, layers: ['basic'], isActive: !deferPassiveGitRefresh, @@ -73,6 +106,10 @@ export const ChatInputWorkspaceStrip: React.FC = ( const showGoal = threadGoal?.visible && !!threadGoal.onOpen; const showPermission = !!permissionControl; const showRightActions = showPermission || showUsage || showGoal; + const isWorktree = !!executionTarget?.worktreeId; + const effectiveWorktreePath = liveWorktree?.path || executionTarget?.rootPath || trimmedPath; + const effectiveWorktreeLifecycle = + liveWorktree?.lifecycle || executionTarget?.lifecycle; const permissionCopy = { ask: { label: t('chatInput.permissionMode.ask.label'), @@ -93,16 +130,20 @@ export const ChatInputWorkspaceStrip: React.FC = ( } satisfies Record; useEffect(() => { - if (!permissionMenuOpen) return; + if (!permissionMenuOpen && !worktreeMenuOpen) return; const handlePointerDown = (event: PointerEvent) => { if (!permissionRootRef.current?.contains(event.target as Node)) { setPermissionMenuOpen(false); } + if (!worktreeRootRef.current?.contains(event.target as Node)) { + setWorktreeMenuOpen(false); + } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setPermissionMenuOpen(false); + setWorktreeMenuOpen(false); } }; @@ -112,7 +153,54 @@ export const ChatInputWorkspaceStrip: React.FC = ( document.removeEventListener('pointerdown', handlePointerDown); document.removeEventListener('keydown', handleKeyDown); }; - }, [permissionMenuOpen]); + }, [permissionMenuOpen, worktreeMenuOpen]); + + useEffect(() => { + if (!worktreeMenuOpen) return; + void configAPI + .getConfig('app.worktrees', { skipRetryOnNotFound: true }) + .then(value => { + if (value && typeof value.branchPrefix === 'string') { + setBranchPrefix(value.branchPrefix); + } + }) + .catch(() => undefined); + }, [worktreeMenuOpen]); + + useEffect(() => { + const worktreeId = executionTarget?.worktreeId; + if (!worktreeId || !projectWorkspacePath) { + setLiveWorktree(null); + return; + } + let cancelled = false; + const refreshWorktree = async () => { + try { + const worktrees = await worktreeAPI.list(projectWorkspacePath); + if (!cancelled) { + setLiveWorktree( + worktrees.find(worktree => worktree.worktreeId === worktreeId) ?? null, + ); + await refreshBasic(); + } + } catch { + if (!cancelled) setLiveWorktree(null); + } + }; + void refreshWorktree(); + const unsubscribe = worktreeAPI.onChanged(event => { + if ( + !event.projectWorkspacePath + || isSamePath(event.projectWorkspacePath, projectWorkspacePath) + ) { + void refreshWorktree(); + } + }); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [executionTarget?.worktreeId, projectWorkspacePath, refreshBasic]); const branchTooltipContent = useMemo( () => @@ -126,10 +214,14 @@ export const ChatInputWorkspaceStrip: React.FC = ( return null; } - const branchLabel = - isRepository && currentBranch?.trim() + const branchLabel = liveWorktree?.branch?.trim() + || executionTarget?.branch?.trim() + || (isWorktree && currentBranch?.trim()) + || (isWorktree && executionTarget?.baseCommit + ? tWorktrees('labels.detached', { commit: executionTarget.baseCommit.slice(0, 9) }) + : isRepository && currentBranch?.trim() ? currentBranch.trim() - : '—'; + : '—'); const workspaceTooltipContent = trimmedPath || label; const permissionMode = permissionControl?.mode ?? 'ask'; @@ -168,8 +260,22 @@ export const ChatInputWorkspaceStrip: React.FC = ( {' / '} - - + +
+ + {isWorktree && worktreeMenuOpen ? ( +
+ + {!liveWorktree?.branch && !executionTarget.branch ? ( + + ) : null} + {effectiveWorktreeLifecycle === 'managed' ? ( + + ) : null} + +
+ ) : null} +
) : null} @@ -317,6 +497,30 @@ export const ChatInputWorkspaceStrip: React.FC = ( ) : null} ) : null} + setBranchDialogOpen(false)} + onConfirm={branch => { + if (!projectWorkspacePath || !executionTarget?.worktreeId) return; + setWorktreeMutationPending(true); + void worktreeAPI + .createBranch( + projectWorkspacePath, + executionTarget.worktreeId, + branch, + globalThis.crypto?.randomUUID?.() ?? `worktree-${Date.now()}`, + ) + .then(() => notificationService.success(tWorktrees('manager.branchCreated'))) + .catch(error => notificationService.error( + error instanceof Error ? error.message : String(error), + )) + .finally(() => setWorktreeMutationPending(false)); + }} + title={tWorktrees('manager.branchDialog.title')} + description={tWorktrees('manager.branchDialog.description')} + defaultValue={`${branchPrefix}${executionTarget?.worktreeId?.slice(0, 8) ?? ''}`} + confirmText={tWorktrees('manager.createBranch')} + /> ); }; diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index b4fe3641ad..5745d2f9c8 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -52,6 +52,7 @@ import { } from '../../store/deepReviewActionBarStore'; import {loadPersistedReviewState} from '../../services/ReviewActionBarPersistenceService'; import type {ReviewActionPersistedState} from '@/shared/types/session-history'; +import {sessionProjectWorkspacePath} from '../../utils/sessionWorkspace'; import { collectModifiedFilePathsFromTurns, hasOpaqueWorkspaceMutationRisk, @@ -780,7 +781,7 @@ export const BtwSessionPanel: React.FC = ({ // action state is more specific for fix/review recovery than that projection. if (!canReplaceDerivedReviewState && currentActionState && currentActionState.phase !== 'idle') return; - const workspacePath = childSession.workspacePath; + const workspacePath = sessionProjectWorkspacePath(childSession); if (!workspacePath) return; let cancelled = false; diff --git a/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts b/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts index baf22005ea..85b1c5193a 100644 --- a/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts +++ b/src/web-ui/src/flow_chat/deep-review/launch/DeepReviewService.ts @@ -37,6 +37,7 @@ import { type DeepReviewLaunchStep, type FailedDeepReviewCleanupResult, } from './launchErrors'; +import { sessionProjectWorkspacePath } from '../../utils/sessionWorkspace'; export { DEEP_REVIEW_SLASH_COMMAND, @@ -86,7 +87,9 @@ async function cleanupFailedDeepReviewLaunch( ): Promise { const cleanupIssues: string[] = []; const childSession = flowChatStore.getState().sessions.get(childSessionId); - const workspacePath = childSession?.workspacePath; + const workspacePath = childSession + ? sessionProjectWorkspacePath(childSession) + : undefined; const remoteConnectionId = childSession?.remoteConnectionId; const remoteSshHost = childSession?.remoteSshHost; diff --git a/src/web-ui/src/flow_chat/services/BtwThreadService.ts b/src/web-ui/src/flow_chat/services/BtwThreadService.ts index bf7e3f4941..97c715961f 100644 --- a/src/web-ui/src/flow_chat/services/BtwThreadService.ts +++ b/src/web-ui/src/flow_chat/services/BtwThreadService.ts @@ -103,6 +103,11 @@ export async function createBtwChildSession(params: { const childSessionName = params.childSessionName.trim() || 'Side thread'; const remoteConnectionId = parentSession?.remoteConnectionId; const remoteSshHost = parentSession?.remoteSshHost; + const projectWorkspacePath = + parentSession?.projectWorkspacePath + || parentSession?.config.projectWorkspacePath + || workspacePath; + const inheritedExecutionTarget = parentSession?.config.executionTarget; const relationship: SessionRelationship | undefined = childSessionKind === 'btw' ? undefined @@ -113,13 +118,19 @@ export async function createBtwChildSession(params: { parentDialogTurnId, parentTurnIndex, }; - const childSessionId = shouldPersistStandaloneSession - ? ( - await agentAPI.createSession({ + const createdSession = shouldPersistStandaloneSession + ? await agentAPI.createSession({ sessionId: buildPersistentReviewSessionId(requestId), sessionName: childSessionName, agentType, workspacePath, + projectWorkspacePath, + executionTarget: inheritedExecutionTarget?.worktreeId + ? { + kind: 'existingWorktree', + worktreeId: inheritedExecutionTarget.worktreeId, + } + : { kind: 'local' }, workspaceId: parentSession?.workspaceId, remoteConnectionId, remoteSshHost, @@ -136,13 +147,14 @@ export async function createBtwChildSession(params: { remoteSshHost, }, }) - ).sessionId - : createBtwRequestId('btw_session'); + : null; + const childSessionId = createdSession?.sessionId ?? createBtwRequestId('btw_session'); + const childWorkspacePath = createdSession?.workspacePath || workspacePath; flowChatStore.addExternalSession( childSessionId, childSessionName, agentType, - workspacePath, + childWorkspacePath, { parentSessionId, sessionKind: childSessionKind, @@ -155,6 +167,11 @@ export async function createBtwChildSession(params: { deepReviewRunManifest: params.deepReviewRunManifest, reviewTargetEvidence: params.reviewTargetEvidence, reviewTargetFilePaths: params.reviewTargetFilePaths, + projectWorkspacePath: + createdSession?.projectWorkspacePath || projectWorkspacePath, + executionTarget: + createdSession?.executionTarget || inheritedExecutionTarget, + workspaceId: createdSession?.workspaceId || parentSession?.workspaceId, isTransient: params.isTransient ?? false, agentBackedTransient: params.isTransient ?? false, }, @@ -222,6 +239,12 @@ export function createBtwSessionPlaceholder(params: { }, isTransient: false, agentBackedTransient: false, + projectWorkspacePath: + parentSession.projectWorkspacePath + || parentSession.config.projectWorkspacePath + || workspacePath, + executionTarget: parentSession.config.executionTarget, + workspaceId: parentSession.workspaceId, }, parentSession.remoteConnectionId, parentSession.remoteSshHost diff --git a/src/web-ui/src/flow_chat/services/ReviewActionBarPersistenceService.ts b/src/web-ui/src/flow_chat/services/ReviewActionBarPersistenceService.ts index a946a1db9a..3542c0d200 100644 --- a/src/web-ui/src/flow_chat/services/ReviewActionBarPersistenceService.ts +++ b/src/web-ui/src/flow_chat/services/ReviewActionBarPersistenceService.ts @@ -11,6 +11,7 @@ import { flowChatStore } from '../store/FlowChatStore'; import { buildSessionMetadata } from '../utils/sessionMetadata'; import type { ReviewActionBarState } from '../store/deepReviewActionBarStore'; import type { ReviewActionPersistedState, SessionMetadata } from '@/shared/types/session-history'; +import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; const log = createLogger('ReviewActionBarPersistence'); @@ -19,6 +20,8 @@ export async function persistReviewActionState(state: ReviewActionBarState): Pro const session = flowChatStore.getState().sessions.get(state.childSessionId); if (!session?.workspacePath) return; + const projectWorkspacePath = sessionProjectWorkspacePath(session); + if (!projectWorkspacePath) return; const stateReviewTargetFilePaths = state.reviewTargetFilePaths ?? []; const remediationModifiedFilePaths = state.remediationModifiedFilePaths ?? []; @@ -62,7 +65,7 @@ export async function persistReviewActionState(state: ReviewActionBarState): Pro try { existingMetadata = await sessionAPI.loadSessionMetadata( state.childSessionId, - session.workspacePath, + projectWorkspacePath, session.remoteConnectionId, session.remoteSshHost ); @@ -80,7 +83,7 @@ export async function persistReviewActionState(state: ReviewActionBarState): Pro await sessionAPI.saveSessionMetadata( metadata, - session.workspacePath, + projectWorkspacePath, ['reviewActionState'], session.remoteConnectionId, session.remoteSshHost diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 2d1ac4f33f..6b9bffeb33 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -442,6 +442,12 @@ function ensureSubagentSession( : undefined, }, focusedReviewDisplayLabel, + projectWorkspacePath: + parentSession?.projectWorkspacePath + || parentSession?.config.projectWorkspacePath + || parentSession?.workspacePath, + executionTarget: parentSession?.config.executionTarget, + workspaceId: parentSession?.workspaceId, }, parentSession?.remoteConnectionId || extractEventRemoteConnectionId(event), parentSession?.remoteSshHost || extractEventRemoteSshHost(event), @@ -889,6 +895,20 @@ function handleSessionCreated(context: FlowChatContext, event: any): void { const store = FlowChatStore.getInstance(); const existing = store.getState().sessions.get(sessionId); const workspacePath = resolveExternalSessionWorkspacePath(context, event); + const projectWorkspacePath = + (typeof event.projectWorkspacePath === 'string' && event.projectWorkspacePath) + || (typeof event.project_workspace_path === 'string' && event.project_workspace_path) + || workspacePath; + const executionTarget = + event.executionTarget && typeof event.executionTarget === 'object' + ? event.executionTarget + : event.execution_target && typeof event.execution_target === 'object' + ? event.execution_target + : undefined; + const workspaceId = + (typeof event.workspaceId === 'string' && event.workspaceId) + || (typeof event.workspace_id === 'string' && event.workspace_id) + || undefined; const remoteConnectionId = extractEventRemoteConnectionId(event); const remoteSshHost = extractEventRemoteSshHost(event); @@ -899,7 +919,11 @@ function handleSessionCreated(context: FlowChatContext, event: any): void { sessionName || 'Remote Session', agentType || 'agentic', workspacePath, - undefined, + { + projectWorkspacePath, + executionTarget, + workspaceId, + }, remoteConnectionId, remoteSshHost ); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index f14bc74a3f..fa74ad0af2 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -12,6 +12,7 @@ import { DEFERRED_TOOL_GATEWAY_NAME, effectiveToolInvocation, } from '../../utils/toolInvocationIdentity'; +import { requireSessionProjectWorkspacePath } from '../../utils/sessionWorkspace'; const log = createLogger('PersistenceModule'); const COALESCED_IMMEDIATE_SAVE_DELAY_MS = 500; @@ -280,7 +281,7 @@ async function performSaveDialogTurnToDisk( return; } - const workspacePath = requireWorkspacePath(sessionId, session.workspacePath); + const workspacePath = requireSessionProjectWorkspacePath(session, sessionId); const dialogTurn = session.dialogTurns.find(turn => turn.id === turnId); if (!dialogTurn) { @@ -521,7 +522,7 @@ export async function updateSessionMetadata( if (!session) return; if (isTransientSession(session)) return; - const workspacePath = requireWorkspacePath(sessionId, session.workspacePath); + const workspacePath = requireSessionProjectWorkspacePath(session, sessionId); let existingMetadata: any = null; try { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 0f4cd7d7d4..fd842eae4b 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -44,6 +44,10 @@ import { DEFAULT_CHAT_INPUT_MODE_CONFIG_PATH, normalizeUserDefaultChatInputModeId, } from '../../utils/chatInputMode'; +import { + requireSessionProjectWorkspacePath, + sessionProjectWorkspacePath, +} from '../../utils/sessionWorkspace'; const log = createLogger('SessionModule'); const pendingSessionCreations = new Map>(); @@ -640,6 +644,7 @@ export async function createChatSession( if (!workspacePath) { throw new Error('Workspace path is required to create a session'); } + const projectWorkspacePath = config.projectWorkspacePath || workspacePath; const remoteConnectionId = workspace?.workspaceKind === WorkspaceKind.Remote ? workspace.connectionId : undefined; const remoteSshHost = @@ -654,7 +659,11 @@ export async function createChatSession( : remoteConnectionId != null && remoteConnectionId !== '' ? `${remoteConnectionId}\n${workspacePath}` : workspacePath; - const creationKey = JSON.stringify([workspaceCreationKey, agentType]); + const creationKey = JSON.stringify([ + workspaceCreationKey, + agentType, + config.executionTargetRequest ?? { kind: 'local' }, + ]); const pendingCreation = pendingSessionCreations.get(creationKey); if (pendingCreation) { @@ -694,6 +703,9 @@ export async function createChatSession( sessionName, agentType, workspacePath, + projectWorkspacePath, + executionTarget: config.executionTargetRequest, + requestId: globalThis.crypto?.randomUUID?.() ?? `worktree-${Date.now()}-${Math.random()}`, workspaceId: mergedConfig.workspaceId, remoteConnectionId, remoteSshHost, @@ -709,14 +721,26 @@ export async function createChatSession( } }); + const effectiveWorkspacePath = + response.workspacePath || response.executionTarget?.rootPath || workspacePath; + const effectiveProjectWorkspacePath = + response.projectWorkspacePath || projectWorkspacePath || workspacePath; + const resolvedConfig: SessionConfig = { + ...mergedConfig, + workspacePath: effectiveWorkspacePath, + projectWorkspacePath: effectiveProjectWorkspacePath, + workspaceId: response.workspaceId ?? mergedConfig.workspaceId, + executionTarget: response.executionTarget, + }; + context.flowChatStore.createSession( response.sessionId, - mergedConfig, + resolvedConfig, undefined, sessionName, maxContextTokens, agentType, - workspacePath, + effectiveWorkspacePath, remoteConnectionId, remoteSshHost, titleDescriptor, @@ -777,7 +801,7 @@ export async function switchChatSession( } touchSessionActivity( sessionId, - latestSession.workspacePath, + sessionProjectWorkspacePath(latestSession), latestSession.remoteConnectionId, latestSession.remoteSshHost ).catch(error => { @@ -910,7 +934,7 @@ export async function archiveChatSession( await sessionAPI.archiveSession( sessionId, - requireSessionWorkspacePath(session.workspacePath, sessionId), + requireSessionProjectWorkspacePath(session, sessionId), session.remoteConnectionId, session.remoteSshHost, ); @@ -958,7 +982,7 @@ export async function renameChatSessionTitle( const updatedTitle = await agentAPI.updateSessionTitle({ sessionId, title: trimmedTitle, - workspacePath: session.workspacePath, + workspacePath: sessionProjectWorkspacePath(session), remoteConnectionId: session.remoteConnectionId, remoteSshHost: session.remoteSshHost, }); @@ -977,15 +1001,19 @@ export async function forkChatSession( throw new Error(`Session does not exist: ${sourceSessionId}`); } - const workspacePath = requireSessionWorkspacePath( + const executionWorkspacePath = requireSessionWorkspacePath( sourceSession.workspacePath, sourceSessionId ); + const projectWorkspacePath = requireSessionProjectWorkspacePath( + sourceSession, + sourceSessionId, + ); const response = await sessionAPI.forkSession( sourceSessionId, sourceTurnId, - workspacePath, + projectWorkspacePath, sourceSession.remoteConnectionId, sourceSession.remoteSshHost ); @@ -996,7 +1024,8 @@ export async function forkChatSession( response.sessionId, { ...sourceSession.config, - workspacePath, + workspacePath: executionWorkspacePath, + projectWorkspacePath, workspaceId: sourceSession.workspaceId, remoteConnectionId: sourceSession.remoteConnectionId, remoteSshHost: sourceSession.remoteSshHost, @@ -1005,7 +1034,7 @@ export async function forkChatSession( response.sessionName, sourceSession.maxContextTokens, sourceSession.mode, - workspacePath, + executionWorkspacePath, sourceSession.remoteConnectionId, sourceSession.remoteSshHost, createTextSessionTitleDescriptor(response.sessionName), @@ -1016,7 +1045,7 @@ export async function forkChatSession( await context.flowChatStore.loadSessionHistory( response.sessionId, - workspacePath, + projectWorkspacePath, undefined, sourceSession.remoteConnectionId, sourceSession.remoteSshHost, @@ -1048,6 +1077,7 @@ export async function ensureBackendSession( const latestSession = context.flowChatStore.getState().sessions.get(sessionId) ?? session; const workspacePath = requireSessionWorkspacePath(latestSession.workspacePath, sessionId); + const projectWorkspacePath = requireSessionProjectWorkspacePath(latestSession, sessionId); // Resolve effective connection info: prefer the current workspace's // connection_id over the session's stored value. When the user changes @@ -1108,7 +1138,7 @@ export async function ensureBackendSession( const ensureCoordinator = async () => { await agentAPI.ensureCoordinatorSession({ sessionId, - workspacePath, + workspacePath: projectWorkspacePath, remoteConnectionId: effectiveConnectionId, remoteSshHost: effectiveSshHost, includeInternal: latestSession.sessionKind === 'subagent', @@ -1122,7 +1152,7 @@ export async function ensureBackendSession( } const restoreKey = [ sessionId, - workspacePath, + projectWorkspacePath, effectiveConnectionId ?? '', effectiveSshHost ?? '', ].join('\u001f'); @@ -1169,6 +1199,14 @@ export async function ensureBackendSession( `Session ${sessionId.slice(0, 8)}`, agentType: latestSession.mode || 'agentic', workspacePath, + projectWorkspacePath, + executionTarget: + latestSession.config.executionTarget?.worktreeId + ? { + kind: 'existingWorktree', + worktreeId: latestSession.config.executionTarget.worktreeId, + } + : { kind: 'local' }, workspaceId: latestSession.workspaceId, remoteConnectionId: effectiveConnectionId, remoteSshHost: effectiveSshHost, @@ -1203,6 +1241,7 @@ export async function retryCreateBackendSession( } const workspacePath = requireSessionWorkspacePath(session.workspacePath, sessionId); + const projectWorkspacePath = requireSessionProjectWorkspacePath(session, sessionId); await agentAPI.createSession({ sessionId: sessionId, @@ -1211,6 +1250,14 @@ export async function retryCreateBackendSession( `Session ${sessionId.slice(0, 8)}`, agentType: session.mode || 'agentic', workspacePath, + projectWorkspacePath, + executionTarget: + session.config.executionTarget?.worktreeId + ? { + kind: 'existingWorktree', + worktreeId: session.config.executionTarget.worktreeId, + } + : { kind: 'local' }, workspaceId: session.workspaceId, remoteConnectionId: session.remoteConnectionId, remoteSshHost: session.remoteSshHost, diff --git a/src/web-ui/src/flow_chat/services/goalService.ts b/src/web-ui/src/flow_chat/services/goalService.ts index 4a85f062ae..dfbecd51f8 100644 --- a/src/web-ui/src/flow_chat/services/goalService.ts +++ b/src/web-ui/src/flow_chat/services/goalService.ts @@ -4,6 +4,7 @@ import type { Session } from '../types/flow-chat'; import { flowChatStore } from '../store/FlowChatStore'; import { pendingQueueManager } from './flow-chat-manager/PendingQueueModule'; import type { GoalCommandAction } from './goalCommandParser'; +import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; export { isGoalSlashCommand, parseGoalCommand } from './goalCommandParser'; export type { GoalCommandAction } from './goalCommandParser'; @@ -106,7 +107,7 @@ function syncGoalToStore(sessionId: string, goal: ThreadGoalSnapshot | null): vo async function sessionRequestBase(session: Session) { return { sessionId: session.sessionId, - workspacePath: session.workspacePath, + workspacePath: sessionProjectWorkspacePath(session), remoteConnectionId: session.remoteConnectionId, remoteSshHost: session.remoteSshHost, }; diff --git a/src/web-ui/src/flow_chat/services/usageReportService.ts b/src/web-ui/src/flow_chat/services/usageReportService.ts index a2d94e827b..983da658d0 100644 --- a/src/web-ui/src/flow_chat/services/usageReportService.ts +++ b/src/web-ui/src/flow_chat/services/usageReportService.ts @@ -4,6 +4,7 @@ import { notificationService } from '@/shared/notification-system'; import type { DialogTurnData } from '@/shared/types/session-history'; import { flowChatStore } from '../store/FlowChatStore'; import type { DialogTurn, Session } from '../types/flow-chat'; +import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; const UNKNOWN_MODEL_ID = 'unknown_model'; const LEGACY_MODEL_LABEL = 'Legacy model not tracked'; @@ -40,6 +41,11 @@ export async function runUsageReportCommand( } const requestedAt = Date.now(); + const projectWorkspacePath = sessionProjectWorkspacePath(params.session); + if (!projectWorkspacePath) { + notificationService.error(params.noWorkspaceMessage); + return { inserted: false, reason: 'missing_workspace' }; + } const pendingReportId = `pending-${params.session.sessionId}-${requestedAt}`; const pendingTurn = flowChatStore.addLocalUsageReportTurn({ sessionId: params.session.sessionId, @@ -54,7 +60,7 @@ export async function runUsageReportCommand( try { const rawReport = await sessionAPI.getSessionUsageReport({ sessionId: params.session.sessionId, - workspacePath: params.session.workspacePath, + workspacePath: projectWorkspacePath, remoteConnectionId: params.session.remoteConnectionId, remoteSshHost: params.session.remoteSshHost, includeHiddenSubagents: true, @@ -81,7 +87,7 @@ export async function runUsageReportCommand( if (turn) { await sessionAPI.saveSessionTurn( toPersistedLocalReportTurn(turn), - params.session.workspacePath, + projectWorkspacePath, params.session.remoteConnectionId, params.session.remoteSshHost, ); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 6484ffe50a..9453846036 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -42,6 +42,7 @@ import { deriveSessionRelationshipFromMetadata, normalizeSessionRelationship, } from '../utils/sessionMetadata'; +import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; import type { SessionTitleDescriptor } from '../utils/sessionTitle'; import { deriveSessionTitleState, @@ -1146,7 +1147,7 @@ export class FlowChatStore { return false; } - const workspacePath = session.workspacePath || session.config.workspacePath; + const workspacePath = sessionProjectWorkspacePath(session); if (!workspacePath || session.dialogTurns.length === 0) { return false; } @@ -1799,6 +1800,7 @@ export class FlowChatStore { lastUserDialogMode: undefined, lastSubmittedMode: undefined, workspacePath, + projectWorkspacePath: config.projectWorkspacePath, workspaceId: config.workspaceId, remoteConnectionId, remoteSshHost, @@ -1843,6 +1845,9 @@ export class FlowChatStore { focusedReviewDisplayLabel?: Session['focusedReviewDisplayLabel']; reviewTargetEvidence?: Session['reviewTargetEvidence']; reviewTargetFilePaths?: Session['reviewTargetFilePaths']; + projectWorkspacePath?: string; + executionTarget?: Session['config']['executionTarget']; + workspaceId?: string; }, remoteConnectionId?: string, remoteSshHost?: string @@ -1866,7 +1871,15 @@ export class FlowChatStore { titleStatus: 'generated', dialogTurns: [], status: 'idle', - config: { maxContextTokens: 128128, autoCompact: true, enableTools: true } as any, + config: { + maxContextTokens: 128128, + autoCompact: true, + enableTools: true, + workspacePath, + projectWorkspacePath: meta?.projectWorkspacePath, + executionTarget: meta?.executionTarget, + workspaceId: meta?.workspaceId, + } as any, createdAt: Date.now(), lastActiveAt: Date.now(), lastFinishedAt: undefined, @@ -1878,6 +1891,8 @@ export class FlowChatStore { isHistorical: false, historyState: 'new', workspacePath, + projectWorkspacePath: meta?.projectWorkspacePath, + workspaceId: meta?.workspaceId, remoteConnectionId, remoteSshHost, parentSessionId: relationship.parentSessionId, @@ -2304,7 +2319,7 @@ export class FlowChatStore { const deleteResults = await Promise.allSettled( sessionIdsToDelete.map(async id => { const sess = this.state.sessions.get(id); - const workspacePath = sess?.workspacePath; + const workspacePath = sess ? sessionProjectWorkspacePath(sess) : undefined; if (!workspacePath) { throw new Error(`Workspace path not found for session ${id}`); } @@ -3475,7 +3490,7 @@ export class FlowChatStore { return; } - const workspacePath = session.workspacePath; + const workspacePath = sessionProjectWorkspacePath(session); if (!workspacePath) { log.warn('Workspace path not available, skipping save', { sessionId, turnId }); return; @@ -3794,6 +3809,9 @@ export class FlowChatStore { config: { agentType: validatedAgentType, modelName: metadata.modelName, + workspacePath: metadata.workspacePath || workspacePath, + projectWorkspacePath: metadata.projectWorkspacePath || workspacePath, + executionTarget: metadata.executionTarget, }, createdAt: metadata.createdAt, lastActiveAt: metadata.lastActiveAt, @@ -3807,6 +3825,7 @@ export class FlowChatStore { lastUserDialogMode: metadata.lastUserDialogAgentType, lastSubmittedMode: metadata.lastSubmittedAgentType, workspacePath: (metadata as any).workspacePath || workspacePath, + projectWorkspacePath: metadata.projectWorkspacePath || workspacePath, remoteConnectionId: metadata.remoteConnectionId || remoteConnectionId, remoteSshHost: metadata.remoteSshHost || metadata.workspaceHostname || remoteSshHost, @@ -4159,6 +4178,9 @@ export class FlowChatStore { config: { agentType: validatedAgentType, modelName: metadata.modelName, + workspacePath: metadata.workspacePath || workspacePath, + projectWorkspacePath: metadata.projectWorkspacePath || workspacePath, + executionTarget: metadata.executionTarget, }, createdAt: metadata.createdAt, lastActiveAt: metadata.lastActiveAt, @@ -4172,6 +4194,7 @@ export class FlowChatStore { lastUserDialogMode: metadata.lastUserDialogAgentType, lastSubmittedMode: metadata.lastSubmittedAgentType, workspacePath: (metadata as any).workspacePath || workspacePath, + projectWorkspacePath: metadata.projectWorkspacePath || workspacePath, remoteConnectionId: metadata.remoteConnectionId || remoteConnectionId, remoteSshHost: metadata.remoteSshHost || metadata.workspaceHostname || remoteSshHost, @@ -4420,6 +4443,13 @@ export class FlowChatStore { sessionTraceId, }); const initialSession = this.state.sessions.get(sessionId); + // The caller remains authoritative for legacy and remote sessions. Only a + // persisted dual-root binding may redirect history storage to the project + // root; otherwise a stale in-memory execution path can cross workspaces. + const storageWorkspacePath = + initialSession?.projectWorkspacePath + || initialSession?.config.projectWorkspacePath + || workspacePath; const suppressInitialHydratingState = !remote && options?.deferFullHistoryUntilActive === true && @@ -4457,7 +4487,7 @@ export class FlowChatStore { // // Peer Device Mode: cloud turn fetch is paused on the controller; session // history must come from the peer host via restore_session_view. - if (!remote && workspacePath && !isPeerDeviceModeActive()) { + if (!remote && storageWorkspacePath && !isPeerDeviceModeActive()) { const relayImportStartedAt = nowMs(); startupTrace.markPhase('historical_session_relay_import_start', { remote, @@ -4470,7 +4500,7 @@ export class FlowChatStore { ); const fetched = await remoteConnectAPI.accountFetchSessionTurns( sessionId, - workspacePath + storageWorkspacePath ); startupTrace.markPhase('historical_session_relay_import_end', { remote, @@ -4521,7 +4551,7 @@ export class FlowChatStore { try { const restoredPromise = agentAPI.restoreSessionWithTurns( sessionId, - workspacePath, + storageWorkspacePath, remoteConnectionId, remoteSshHost, sessionTraceId, @@ -4551,7 +4581,7 @@ export class FlowChatStore { const restoredSessionPromise = agentAPI.restoreSession( sessionId, - workspacePath, + storageWorkspacePath, remoteConnectionId, remoteSshHost, sessionTraceId, @@ -4569,7 +4599,7 @@ export class FlowChatStore { try { const restoredPromise = agentAPI.restoreSessionView( sessionId, - workspacePath, + storageWorkspacePath, remoteConnectionId, remoteSshHost, sessionTraceId, @@ -4642,7 +4672,7 @@ export class FlowChatStore { const { sessionAPI } = await import('@/infrastructure/api/service-api/SessionAPI'); turns = await sessionAPI.loadSessionTurns( sessionId, - workspacePath, + storageWorkspacePath, limit, remoteConnectionId, remoteSshHost @@ -4820,7 +4850,7 @@ export class FlowChatStore { if (!deferFullHistoryUntilActive) { this.scheduleCompleteSessionHistoryLoad({ sessionId, - workspacePath, + workspacePath: storageWorkspacePath, remoteConnectionId, remoteSshHost, includeInternal: options?.includeInternal, diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 5d4f80a563..c9823dbd1d 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -381,6 +381,9 @@ export interface Session { // Sessions are always kept in store for event processing; only display is filtered. workspacePath?: string; + /** Main project that owns this session when `workspacePath` is a linked worktree. */ + projectWorkspacePath?: string; + /** Stable backend id — always set for new sessions; do not infer workspace from path alone. */ workspaceId?: string; @@ -493,6 +496,12 @@ export interface SessionConfig { agentType?: string; context?: Record; workspacePath?: string; + /** Main project scope used for persistence when execution happens in a worktree. */ + projectWorkspacePath?: string; + /** Requested target used only while creating a new session. */ + executionTargetRequest?: import('@/infrastructure/api/service-api/WorktreeAPI').SessionExecutionTargetRequest; + /** Resolved target returned and persisted by the backend. */ + executionTarget?: import('@/infrastructure/api/service-api/WorktreeAPI').SessionExecutionTarget; /** Binds session to `WorkspaceInfo.id` (path alone is insufficient for remotes). */ workspaceId?: string; /** Disambiguates sessions when multiple remote workspaces share the same `workspacePath`. */ diff --git a/src/web-ui/src/flow_chat/utils/sessionMetadata.ts b/src/web-ui/src/flow_chat/utils/sessionMetadata.ts index 19e3c85ec7..9707f651ce 100644 --- a/src/web-ui/src/flow_chat/utils/sessionMetadata.ts +++ b/src/web-ui/src/flow_chat/utils/sessionMetadata.ts @@ -336,6 +336,7 @@ export function buildSessionMetadata( | 'config' | 'createdAt' | 'workspacePath' + | 'projectWorkspacePath' | 'remoteConnectionId' | 'remoteSshHost' | 'todos' @@ -410,6 +411,13 @@ export function buildSessionMetadata( ), todos: session.todos || existingMetadata?.todos || [], workspacePath: session.workspacePath || existingMetadata?.workspacePath, + projectWorkspacePath: + session.projectWorkspacePath + || session.config.projectWorkspacePath + || session.workspacePath + || existingMetadata?.projectWorkspacePath, + executionTarget: + session.config.executionTarget ?? existingMetadata?.executionTarget, remoteConnectionId: session.remoteConnectionId ?? existingMetadata?.remoteConnectionId, remoteSshHost: session.remoteSshHost ?? existingMetadata?.remoteSshHost, diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts index 86e65e094c..1bea75ea0b 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts @@ -158,4 +158,20 @@ describe('sessionOrdering', () => { ) ).toBe(true); }); + + it('groups a worktree execution session under its main project', () => { + const session = { + workspacePath: '/worktrees/project/wt-1', + projectWorkspacePath: '/projects/project', + remoteConnectionId: undefined, + remoteSshHost: undefined, + }; + + expect( + sessionBelongsToWorkspaceNavRow(session, '/projects/project') + ).toBe(true); + expect( + sessionBelongsToWorkspaceNavRow(session, '/projects/other') + ).toBe(false); + }); }); diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts index d837c32ca9..8d0b75be87 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts @@ -25,7 +25,7 @@ function effectiveWorkspaceSshHost( * We must never treat "same host" as sufficient: two tabs to the same server at `/a` vs `/b` are distinct. */ export function sessionBelongsToWorkspaceNavRow( - session: Pick & { + session: Pick & { workspaceHostname?: string | null; }, workspacePath: string, @@ -33,9 +33,17 @@ export function sessionBelongsToWorkspaceNavRow( remoteSshHost?: string | null ): boolean { const sessionRoot = session.workspacePath || workspacePath; + const projectRoot = session.projectWorkspacePath; const pathsMatch = isSamePath(sessionRoot, workspacePath) || - normalizeRemoteWorkspacePath(sessionRoot) === normalizeRemoteWorkspacePath(workspacePath); + normalizeRemoteWorkspacePath(sessionRoot) === normalizeRemoteWorkspacePath(workspacePath) || + Boolean( + projectRoot && + ( + isSamePath(projectRoot, workspacePath) || + normalizeRemoteWorkspacePath(projectRoot) === normalizeRemoteWorkspacePath(workspacePath) + ) + ); const wsConn = remoteConnectionId?.trim() ?? ''; const sessConn = session.remoteConnectionId?.trim() ?? ''; diff --git a/src/web-ui/src/flow_chat/utils/sessionWorkspace.test.ts b/src/web-ui/src/flow_chat/utils/sessionWorkspace.test.ts new file mode 100644 index 0000000000..e6df1ecbb7 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/sessionWorkspace.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import type { Session } from '../types/flow-chat'; +import { + requireSessionProjectWorkspacePath, + sessionExecutionWorkspacePath, + sessionProjectWorkspacePath, +} from './sessionWorkspace'; + +function session( + values: Partial>, +): Pick { + return { + workspacePath: undefined, + projectWorkspacePath: undefined, + config: {}, + ...values, + }; +} + +describe('sessionWorkspace', () => { + it('keeps execution and project roots distinct for a worktree session', () => { + const worktreeSession = session({ + workspacePath: '/worktrees/wt-1', + projectWorkspacePath: '/repo', + config: { + workspacePath: '/worktrees/wt-1', + projectWorkspacePath: '/repo', + }, + }); + + expect(sessionExecutionWorkspacePath(worktreeSession)).toBe('/worktrees/wt-1'); + expect(sessionProjectWorkspacePath(worktreeSession)).toBe('/repo'); + expect(requireSessionProjectWorkspacePath(worktreeSession, 'session-1')).toBe('/repo'); + }); + + it('treats legacy sessions as local to their execution root', () => { + const legacySession = session({ + config: { workspacePath: '/repo' }, + }); + + expect(sessionExecutionWorkspacePath(legacySession)).toBe('/repo'); + expect(sessionProjectWorkspacePath(legacySession)).toBe('/repo'); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/sessionWorkspace.ts b/src/web-ui/src/flow_chat/utils/sessionWorkspace.ts new file mode 100644 index 0000000000..1630b40351 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/sessionWorkspace.ts @@ -0,0 +1,35 @@ +import type { Session } from '../types/flow-chat'; + +type SessionWorkspaceBinding = Pick< + Session, + 'workspacePath' | 'projectWorkspacePath' | 'config' +>; + +/** Concrete root in which terminal, Git, and file tools execute. */ +export function sessionExecutionWorkspacePath( + session: SessionWorkspaceBinding, +): string | undefined { + return session.workspacePath || session.config.workspacePath; +} + +/** Main-project root that owns session persistence and orchestration state. */ +export function sessionProjectWorkspacePath( + session: SessionWorkspaceBinding, +): string | undefined { + return ( + session.projectWorkspacePath + || session.config.projectWorkspacePath + || sessionExecutionWorkspacePath(session) + ); +} + +export function requireSessionProjectWorkspacePath( + session: SessionWorkspaceBinding, + sessionId: string, +): string { + const path = sessionProjectWorkspacePath(session); + if (!path) { + throw new Error(`Workspace path not found for session ${sessionId}`); + } + return path; +} diff --git a/src/web-ui/src/infrastructure/api/index.ts b/src/web-ui/src/infrastructure/api/index.ts index e102b4e118..3525ed19e0 100644 --- a/src/web-ui/src/infrastructure/api/index.ts +++ b/src/web-ui/src/infrastructure/api/index.ts @@ -12,6 +12,7 @@ export * from './service-api/CronAPI'; export * from './service-api/PermissionAPI'; export * from './service-api/PageAPI'; export * from './service-api/SpeechAPI'; +export * from './service-api/WorktreeAPI'; // Import API modules import { workspaceAPI } from './service-api/WorkspaceAPI'; @@ -39,9 +40,10 @@ import { editorAiAPI } from './service-api/EditorAiAPI'; import { reviewPlatformAPI } from './service-api/ReviewPlatformAPI'; import { insightsApi } from './insightsApi'; import { speechAPI } from './service-api/SpeechAPI'; +import { worktreeAPI } from './service-api/WorktreeAPI'; // Export API modules -export { workspaceAPI, configAPI, aiApi, toolAPI, agentAPI, systemAPI, projectAPI, diffAPI, snapshotAPI, globalAPI, contextAPI, cronAPI, permissionAPI, pageAPI, gitAPI, gitAgentAPI, gitRepoHistoryAPI, startchatAgentAPI, sessionAPI, i18nAPI, btwAPI, editorAiAPI, reviewPlatformAPI, insightsApi, speechAPI }; +export { workspaceAPI, configAPI, aiApi, toolAPI, agentAPI, systemAPI, projectAPI, diffAPI, snapshotAPI, globalAPI, contextAPI, cronAPI, permissionAPI, pageAPI, gitAPI, gitAgentAPI, gitRepoHistoryAPI, startchatAgentAPI, sessionAPI, i18nAPI, btwAPI, editorAiAPI, reviewPlatformAPI, insightsApi, speechAPI, worktreeAPI }; export * from './service-api/ReviewPlatformAPI'; // Export types @@ -75,6 +77,7 @@ export const bitfunAPI = { reviewPlatform: reviewPlatformAPI, insights: insightsApi, speech: speechAPI, + worktree: worktreeAPI, }; // Default export diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts index f0e80a66cb..f4531f7496 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts @@ -92,4 +92,29 @@ describe('AgentAPI', () => { }); }); + it('preserves structured worktree errors during atomic session creation', async () => { + invokeMock.mockRejectedValueOnce(JSON.stringify({ + code: 'copy_conflict', + message: 'A selected local file already exists in the target worktree', + recoveryPath: '/tmp/recover-worktree', + })); + + await expect(agentAPI.createSession({ + sessionName: 'Isolated task', + agentType: 'agentic', + workspacePath: '/repo', + projectWorkspacePath: '/repo', + requestId: 'request-worktree-1', + executionTarget: { + kind: 'newManagedWorktree', + baseRef: 'HEAD', + copyLocalChanges: true, + }, + })).rejects.toMatchObject({ + name: 'WorktreeCommandError', + code: 'copy_conflict', + recoveryPath: '/tmp/recover-worktree', + }); + }); + }); diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 74e114d48e..3f09d8610a 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -13,6 +13,11 @@ import type { ReviewTargetEvidence, ReviewTeamRunManifest, } from '@/shared/services/reviewTeamService'; +import type { + SessionExecutionTarget, + SessionExecutionTargetRequest, +} from './WorktreeAPI'; +import { toWorktreeCommandError } from './WorktreeAPI'; @@ -49,6 +54,9 @@ export interface CreateSessionRequest { sessionName: string; agentType: string; workspacePath: string; + projectWorkspacePath?: string; + executionTarget?: SessionExecutionTargetRequest; + requestId?: string; workspaceId?: string; remoteConnectionId?: string; remoteSshHost?: string; @@ -64,6 +72,10 @@ export interface CreateSessionResponse { sessionId: string; sessionName: string; agentType: string; + workspacePath?: string; + workspaceId?: string; + projectWorkspacePath?: string; + executionTarget?: SessionExecutionTarget; } @@ -520,6 +532,9 @@ export class AgentAPI { try { return await api.invoke('create_session', { request }); } catch (error) { + if (request.executionTarget && request.executionTarget.kind !== 'local') { + throw toWorktreeCommandError(error); + } throw createTauriCommandError('create_session', error, request); } } diff --git a/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.test.ts new file mode 100644 index 0000000000..75aa1fd0a7 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { WorktreeAPI, WorktreeCommandError } from './WorktreeAPI'; + +const invokeMock = vi.hoisted(() => vi.fn()); +const listenMock = vi.hoisted(() => vi.fn()); + +vi.mock('./ApiClient', () => ({ + api: { + invoke: invokeMock, + listen: listenMock, + }, +})); + +describe('WorktreeAPI', () => { + let api: WorktreeAPI; + + beforeEach(() => { + api = new WorktreeAPI(); + invokeMock.mockReset(); + listenMock.mockReset(); + }); + + it('uses project-scoped commands and never enables force by default', async () => { + invokeMock.mockResolvedValue({ worktreeId: 'wt-1', removed: true }); + + await api.remove('/repo', 'wt-1', 'request-1'); + + expect(invokeMock).toHaveBeenCalledWith('worktree_remove', { + request: { + projectWorkspacePath: '/repo', + worktreeId: 'wt-1', + requestId: 'request-1', + force: false, + }, + }); + }); + + it('preserves stable structured error codes', async () => { + const transportError = Object.assign(new Error('command failed'), { + data: { + code: 'dirty_worktree', + message: 'The worktree contains local changes', + }, + }); + invokeMock.mockRejectedValue(transportError); + + await expect(api.remove('/repo', 'wt-1', 'request-2')).rejects.toMatchObject({ + name: 'WorktreeCommandError', + code: 'dirty_worktree', + message: 'The worktree contains local changes', + } satisfies Partial); + }); + + it('subscribes to event-driven worktree updates', () => { + const unsubscribe = vi.fn(); + const callback = vi.fn(); + listenMock.mockReturnValue(unsubscribe); + + expect(api.onChanged(callback)).toBe(unsubscribe); + expect(listenMock).toHaveBeenCalledWith('worktree://changed', callback); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.ts b/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.ts new file mode 100644 index 0000000000..71ea63ee1e --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.ts @@ -0,0 +1,212 @@ +import { api } from './ApiClient'; + +export type SessionExecutionTargetRequest = + | { kind: 'local' } + | { kind: 'newManagedWorktree'; baseRef?: string; copyLocalChanges?: boolean } + | { kind: 'existingWorktree'; worktreeId: string }; + +export type WorktreeLifecycle = 'managed' | 'permanent' | 'external'; +export type WorktreeDefaultTarget = 'local' | 'managedWorktree'; + +export interface WorktreeSettings { + defaultTarget: WorktreeDefaultTarget; + rootPath: string; + branchPrefix: string; + copyLocalChanges: boolean; +} + +export interface SessionExecutionTarget { + kind: 'local' | 'managedWorktree' | 'existingWorktree'; + worktreeId?: string; + rootPath: string; + baseRef?: string; + baseCommit?: string; + branch?: string; + lifecycle?: WorktreeLifecycle; +} + +export interface WorktreeSessionSummary { + sessionId: string; + sessionName: string; + status: string; + archived: boolean; +} + +export interface WorktreeSummary { + worktreeId: string; + projectWorkspacePath: string; + path: string; + head: string; + branch?: string; + lifecycle: WorktreeLifecycle; + isMain: boolean; + dirty: boolean; + locked: boolean; + missing: boolean; + hasUnpublishedCommits: boolean; + associatedSessionCount: number; + runningSessionCount: number; + sessions: WorktreeSessionSummary[]; +} + +export type WorktreeErrorCode = + | 'remote_unsupported' + | 'not_git_repository' + | 'unborn_repo' + | 'invalid_base_ref' + | 'worktree_not_found' + | 'worktree_busy' + | 'worktree_locked' + | 'dirty_worktree' + | 'unpublished_commits' + | 'copy_conflict' + | 'invalid_path' + | 'branch_exists' + | 'request_conflict' + | 'rollback_incomplete' + | 'git_failed' + | 'io_failed'; + +interface WorktreeErrorPayload { + code: WorktreeErrorCode; + message: string; + recoveryPath?: string; +} + +export class WorktreeCommandError extends Error { + constructor( + public readonly code: WorktreeErrorCode, + message: string, + public readonly recoveryPath?: string, + ) { + super(message); + this.name = 'WorktreeCommandError'; + } +} + +export interface WorktreeCreateRequest { + requestId: string; + projectWorkspacePath: string; + sourceWorkspacePath?: string; + baseRef?: string; + copyLocalChanges?: boolean; +} + +export interface WorktreeCreateResult { + worktree: WorktreeSummary; + executionTarget: SessionExecutionTarget; + created: boolean; +} + +export interface WorktreeMutationResult { + worktree: WorktreeSummary; +} + +export interface WorktreeChangedEvent { + projectWorkspacePath: string; +} + +export function toWorktreeCommandError(error: unknown): WorktreeCommandError { + const candidates: unknown[] = [error]; + if (error instanceof Error) { + const enriched = error as Error & { data?: unknown; cause?: unknown }; + candidates.push(enriched.data, enriched.cause, error.message); + } + for (const candidate of candidates) { + let value = candidate; + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch { + continue; + } + } + if (value && typeof value === 'object') { + const payload = value as Partial; + if (typeof payload.code === 'string' && typeof payload.message === 'string') { + return new WorktreeCommandError( + payload.code as WorktreeErrorCode, + payload.message, + payload.recoveryPath, + ); + } + } + } + return new WorktreeCommandError('git_failed', error instanceof Error ? error.message : String(error)); +} + +async function invokeWorktree(command: string, request: unknown): Promise { + try { + return await api.invoke(command, { request }); + } catch (error) { + throw toWorktreeCommandError(error); + } +} + +export class WorktreeAPI { + list(projectWorkspacePath: string): Promise { + return invokeWorktree('worktree_list', { projectWorkspacePath }); + } + + create(request: WorktreeCreateRequest): Promise { + return invokeWorktree('worktree_create', request); + } + + createBranch( + projectWorkspacePath: string, + worktreeId: string, + branch: string, + requestId: string, + ): Promise { + return invokeWorktree('worktree_create_branch', { + projectWorkspacePath, + worktreeId, + branch, + requestId, + }); + } + + promote( + projectWorkspacePath: string, + worktreeId: string, + requestId: string, + ): Promise { + return invokeWorktree('worktree_promote', { + projectWorkspacePath, + worktreeId, + requestId, + }); + } + + remove( + projectWorkspacePath: string, + worktreeId: string, + requestId: string, + force = false, + ): Promise<{ worktreeId: string; removed: boolean }> { + return invokeWorktree('worktree_remove', { + projectWorkspacePath, + worktreeId, + requestId, + force, + }); + } + + recreate( + projectWorkspacePath: string, + worktreeId: string, + requestId: string, + ): Promise { + return invokeWorktree('worktree_recreate', { + projectWorkspacePath, + worktreeId, + requestId, + }); + } + + onChanged(callback: (event: WorktreeChangedEvent) => void): () => void { + return api.listen('worktree://changed', callback); + } +} + +export const worktreeAPI = new WorktreeAPI(); diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss new file mode 100644 index 0000000000..bfb5191573 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss @@ -0,0 +1,12 @@ +.bitfun-worktrees-config { + .config-page-row__control { + min-width: 220px; + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 4px; + } +} diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx new file mode 100644 index 0000000000..935bad0c7a --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx @@ -0,0 +1,186 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { GitBranch, RotateCcw, Save } from 'lucide-react'; +import { + Button, + ConfigPageLoading, + ConfigPageMessage, + Input, + Select, + Switch, +} from '@/component-library'; +import { configAPI } from '@/infrastructure/api'; +import type { WorktreeSettings } from '@/infrastructure/api/service-api/WorktreeAPI'; +import { useI18n } from '@/infrastructure/i18n'; +import { + ConfigPageContent, + ConfigPageHeader, + ConfigPageLayout, + ConfigPageRow, + ConfigPageSection, +} from './common'; +import './WorktreesConfig.scss'; + +const DEFAULT_SETTINGS: WorktreeSettings = { + defaultTarget: 'local', + rootPath: '~/.bitfun/worktrees', + branchPrefix: 'bitfun/', + copyLocalChanges: false, +}; + +const WorktreesConfig: React.FC = () => { + const { t } = useI18n('worktrees'); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ + type: 'success' | 'error' | 'info'; + text: string; + } | null>(null); + + const load = useCallback(async () => { + setLoading(true); + setMessage(null); + try { + const configured = await configAPI.getConfig('app.worktrees', { + skipRetryOnNotFound: true, + }); + setSettings({ + ...DEFAULT_SETTINGS, + ...(configured && typeof configured === 'object' ? configured : {}), + }); + } catch { + setMessage({ type: 'error', text: t('settings.loadFailed') }); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void load(); + }, [load]); + + const save = async () => { + if (!settings.rootPath.trim() || !settings.branchPrefix.trim()) { + setMessage({ type: 'error', text: t('settings.required') }); + return; + } + setSaving(true); + setMessage(null); + try { + await configAPI.setConfig('app.worktrees', { + ...settings, + rootPath: settings.rootPath.trim(), + branchPrefix: settings.branchPrefix.trim(), + }); + setMessage({ type: 'success', text: t('settings.saved') }); + } catch { + setMessage({ type: 'error', text: t('settings.saveFailed') }); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ; + } + + return ( + + } + title={t('settings.title')} + subtitle={t('settings.description')} + /> + + + + + setSettings(current => ({ + ...current, + rootPath: event.target.value, + }))} + disabled={saving} + /> + + + setSettings(current => ({ + ...current, + branchPrefix: event.target.value, + }))} + disabled={saving} + /> + + + setSettings(current => ({ + ...current, + copyLocalChanges: event.target.checked, + }))} + disabled={saving} + /> + + +
+ + +
+
+
+ ); +}; + +export default WorktreesConfig; diff --git a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts index ac5dc68710..4d07466719 100644 --- a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts +++ b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts @@ -43,6 +43,7 @@ export const ALL_NAMESPACES = [ 'settings/voice-input', 'shared', 'tools', + 'worktrees', ] as const; export const WEB_UI_BOOTSTRAP_NAMESPACES = [ diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 38b460f85f..f89dada7f9 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -18,6 +18,7 @@ "basics": "Logging, terminal shell, notifications, and launch at login.", "appearance": "Language, theme, and UI font size.", "models": "AI models, API keys, providers, proxy, and session title.", + "worktrees": "Defaults for isolated Git worktrees and parallel sessions.", "sessionPersonalization": "Agent companion.", "sessionPermissions": "Accelerated workspace search, tool confirmation, Computer use, browser, and debug.", "quickActions": "One-click AI actions after coding. Built-in and customizable prompts.", @@ -42,6 +43,7 @@ "basics": "Basics", "appearance": "Appearance", "models": "Models", + "worktrees": "Worktrees", "sessionPersonalization": "Personalization", "sessionPermissions": "Permissions", "quickActions": "Quick Actions", diff --git a/src/web-ui/src/locales/en-US/worktrees.json b/src/web-ui/src/locales/en-US/worktrees.json new file mode 100644 index 0000000000..59619b863d --- /dev/null +++ b/src/web-ui/src/locales/en-US/worktrees.json @@ -0,0 +1,121 @@ +{ + "actions": { + "cancel": "Cancel", + "close": "Close" + }, + "launcher": { + "title": "New session in a worktree", + "description": "Create an isolated execution directory from an immutable Git commit. Your main project stays unchanged.", + "mode": "Session mode", + "codeMode": "Code", + "coworkMode": "Cowork", + "baseRef": "Base branch or ref", + "baseRefHint": "Enter a branch, tag, or commit.", + "resolvedCommit": "Resolved commit: {{commit}}", + "targetPath": "Target path", + "copyChanges": "Copy local changes", + "copyChangesRequiresHead": "Choose a base that resolves to the source worktree HEAD to enable copying.", + "copyChangesSummary": "{{count}} files: {{staged}} staged, {{unstaged}} unstaged, {{untracked}} untracked. Staging is preserved.", + "checking": "Checking repository…", + "remoteUnsupported": "Worktrees are not supported for remote workspaces yet.", + "notGitRepository": "This workspace is not a Git repository.", + "unbornRepository": "Create the repository's first commit before using worktrees.", + "invalidBaseRef": "The selected branch or ref cannot be resolved.", + "create": "Create in worktree" + }, + "sidebar": { + "worktrees": "Worktrees", + "loadFailed": "Could not load worktrees. Select to retry.", + "recreateRequired": "Recreate this missing worktree first.", + "newSession": "New session in this worktree" + }, + "labels": { + "detached": "detached {{commit}}", + "dirty": "Dirty", + "missing": "Missing", + "running": "{{count}} running", + "unpublished": "Unpublished commits", + "lifecycle": { + "managed": "Managed", + "permanent": "Permanent", + "external": "External" + } + }, + "strip": { + "menuLabel": "Worktree actions", + "open": "Open worktree folder" + }, + "manager": { + "title": "Worktree Manager", + "description": "Manage isolated session directories owned by this project.", + "refresh": "Refresh", + "create": "New worktree session", + "loading": "Loading worktrees…", + "emptyTitle": "No worktrees yet", + "emptyDescription": "Create an isolated session to work in parallel without changing the main directory.", + "sessionCount": "{{count}} associated sessions", + "open": "Open", + "newSession": "New session", + "createBranch": "Create branch", + "recreate": "Recreate", + "keep": "Keep permanently", + "remove": "Remove", + "recreated": "Worktree recreated", + "promoted": "Worktree is now permanent", + "branchCreated": "Branch created", + "removed": "Worktree removed", + "risks": { + "dirty": "The directory contains local changes.", + "unpublished": "Detached HEAD contains commits not reachable from another ref.", + "sessions": "{{count}} sessions are still associated.", + "running": "{{count}} associated sessions are running or unarchived.", + "clean": "The directory is clean and has no unpublished commits." + }, + "branchDialog": { + "title": "Create a branch", + "description": "Attach this detached worktree to a publishable branch.", + "required": "Enter a branch name." + }, + "removeDialog": { + "title": "Remove worktree?", + "message": "Review the worktree state before removing its directory.", + "blocked": "This worktree cannot be removed while associated sessions are running or unarchived.", + "forceTitle": "Force-remove worktree?", + "forceMessage": "This permanently discards the listed local work. Confirm again to continue.", + "forceConfirm": "Force remove" + } + }, + "settings": { + "title": "Worktrees", + "description": "Choose defaults for isolated local sessions. Changes to the root path affect new worktrees only.", + "loading": "Loading worktree settings…", + "loadFailed": "Could not load worktree settings.", + "saveFailed": "Could not save worktree settings.", + "saved": "Worktree settings saved.", + "required": "The root path and branch prefix are required.", + "save": "Save", + "reset": "Restore defaults", + "creation": { + "title": "Session creation", + "description": "Local remains the safe, fast default. Worktree isolation is always available from the new-session menu." + }, + "defaultTarget": { + "label": "Default new-session target", + "description": "Controls the preselected target; quick Code and Cowork buttons still create Local sessions.", + "local": "Local", + "worktree": "Managed worktree" + }, + "rootPath": { + "label": "Worktree root", + "description": "Managed worktrees created later are stored under this directory." + }, + "branchPrefix": { + "label": "Branch prefix", + "description": "Suggested prefix when creating a branch for a detached worktree." + }, + "copyChanges": { + "label": "Copy local changes by default", + "description": "Off by default. Only available when the chosen base matches the source HEAD." + } + } +} diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index 5c6b321d61..2612abe111 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -39,6 +39,7 @@ "basics": "日志、终端 Shell、通知与开机启动。", "appearance": "语言、主题与界面字体大小。", "models": "AI 模型、API 密钥、供应商、代理与会话标题。", + "worktrees": "隔离 Git Worktree 与并行会话的默认设置。", "sessionPersonalization": "Agent 伙伴。", "sessionPermissions": "加速工作区搜索、工具确认与超时、Computer use、浏览器与调试。", "quickActions": "代码完成后的一键 AI 动作,支持内置和自定义指令。", @@ -63,6 +64,7 @@ "basics": "基础", "appearance": "外观", "models": "模型", + "worktrees": "Worktrees", "sessionPersonalization": "个性化", "sessionPermissions": "权限管理", "quickActions": "快捷动作", diff --git a/src/web-ui/src/locales/zh-CN/worktrees.json b/src/web-ui/src/locales/zh-CN/worktrees.json new file mode 100644 index 0000000000..3758a076a7 --- /dev/null +++ b/src/web-ui/src/locales/zh-CN/worktrees.json @@ -0,0 +1,121 @@ +{ + "actions": { + "cancel": "取消", + "close": "关闭" + }, + "launcher": { + "title": "在 Worktree 中新建会话", + "description": "从不可变的 Git 提交创建隔离执行目录,主项目目录不会被修改。", + "mode": "会话模式", + "codeMode": "Code", + "coworkMode": "Cowork", + "baseRef": "基线分支或引用", + "baseRefHint": "可输入分支、标签或提交。", + "resolvedCommit": "已解析提交:{{commit}}", + "targetPath": "目标路径", + "copyChanges": "复制本地改动", + "copyChangesRequiresHead": "仅当基线解析到源 Worktree 的 HEAD 时才能复制。", + "copyChangesSummary": "共 {{count}} 个文件:{{staged}} 个已暂存、{{unstaged}} 个未暂存、{{untracked}} 个未跟踪;会保留暂存状态。", + "checking": "正在检查仓库…", + "remoteUnsupported": "远程 Workspace 暂不支持 Worktree。", + "notGitRepository": "当前 Workspace 不是 Git 仓库。", + "unbornRepository": "请先创建仓库的第一个提交,再使用 Worktree。", + "invalidBaseRef": "无法解析所选分支或引用。", + "create": "在 Worktree 中新建" + }, + "sidebar": { + "worktrees": "Worktrees", + "loadFailed": "无法加载 Worktree,点击重试。", + "recreateRequired": "请先重建这个缺失的 Worktree。", + "newSession": "在此 Worktree 新建会话" + }, + "labels": { + "detached": "游离 {{commit}}", + "dirty": "有改动", + "missing": "路径缺失", + "running": "{{count}} 个运行中", + "unpublished": "有未发布提交", + "lifecycle": { + "managed": "托管", + "permanent": "永久保留", + "external": "外部" + } + }, + "strip": { + "menuLabel": "Worktree 操作", + "open": "打开 Worktree 文件夹" + }, + "manager": { + "title": "Worktree 管理器", + "description": "集中管理当前项目的隔离会话目录。", + "refresh": "刷新", + "create": "新建 Worktree 会话", + "loading": "正在加载 Worktree…", + "emptyTitle": "暂无 Worktree", + "emptyDescription": "新建隔离会话即可并行工作,并保持主目录不变。", + "sessionCount": "关联 {{count}} 个会话", + "open": "打开", + "newSession": "新建会话", + "createBranch": "创建分支", + "recreate": "重建", + "keep": "永久保留", + "remove": "删除", + "recreated": "Worktree 已重建", + "promoted": "Worktree 已永久保留", + "branchCreated": "分支已创建", + "removed": "Worktree 已删除", + "risks": { + "dirty": "目录中存在本地改动。", + "unpublished": "游离 HEAD 包含未被其他引用包含的提交。", + "sessions": "仍关联 {{count}} 个会话。", + "running": "有 {{count}} 个关联会话正在运行或尚未归档。", + "clean": "目录干净,且没有未发布提交。" + }, + "branchDialog": { + "title": "创建分支", + "description": "将这个游离 Worktree 绑定到可发布的分支。", + "required": "请输入分支名称。" + }, + "removeDialog": { + "title": "删除 Worktree?", + "message": "删除目录前请检查以下 Worktree 状态。", + "blocked": "仍有运行中或未归档的关联会话,不能删除这个 Worktree。", + "forceTitle": "强制删除 Worktree?", + "forceMessage": "这会永久丢弃列出的本地工作,请再次确认。", + "forceConfirm": "强制删除" + } + }, + "settings": { + "title": "Worktrees", + "description": "设置本地隔离会话的默认值。修改根目录只影响之后新建的 Worktree。", + "loading": "正在加载 Worktree 设置…", + "loadFailed": "无法加载 Worktree 设置。", + "saveFailed": "无法保存 Worktree 设置。", + "saved": "Worktree 设置已保存。", + "required": "Worktree 根目录和分支前缀不能为空。", + "save": "保存", + "reset": "恢复默认值", + "creation": { + "title": "会话创建", + "description": "Local 保持为快速、安全的默认路径;新会话菜单始终提供 Worktree 隔离选项。" + }, + "defaultTarget": { + "label": "默认新会话目标", + "description": "控制预选目标;Code 和 Cowork 快捷按钮仍直接创建 Local 会话。", + "local": "Local", + "worktree": "托管 Worktree" + }, + "rootPath": { + "label": "Worktree 根目录", + "description": "之后新建的托管 Worktree 会存放在此目录下。" + }, + "branchPrefix": { + "label": "分支前缀", + "description": "为游离 Worktree 创建分支时使用的建议前缀。" + }, + "copyChanges": { + "label": "默认复制本地改动", + "description": "默认关闭;仅当所选基线与源目录 HEAD 一致时可用。" + } + } +} diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json index 1f0be13c5a..01cee44c08 100644 --- a/src/web-ui/src/locales/zh-TW/settings.json +++ b/src/web-ui/src/locales/zh-TW/settings.json @@ -39,6 +39,7 @@ "basics": "日誌、終端 Shell、通知與開機啟動。", "appearance": "語言、主題與介面字體大小。", "models": "AI 模型、API 密鑰、供應商、代理與會話標題。", + "worktrees": "隔離 Git Worktree 與平行工作階段的預設設定。", "sessionPersonalization": "Agent 夥伴。", "sessionPermissions": "加速工作區搜尋、工具確認與逾時、Computer use、瀏覽器與偵錯。", "review": "Review 策略、覆蓋深度、容量、成本和耗時控制。", @@ -63,6 +64,7 @@ "basics": "基礎", "appearance": "外觀", "models": "模型", + "worktrees": "Worktrees", "sessionPersonalization": "個性化", "sessionPermissions": "權限管理", "review": "審核", diff --git a/src/web-ui/src/locales/zh-TW/worktrees.json b/src/web-ui/src/locales/zh-TW/worktrees.json new file mode 100644 index 0000000000..55a41cfe2b --- /dev/null +++ b/src/web-ui/src/locales/zh-TW/worktrees.json @@ -0,0 +1,121 @@ +{ + "actions": { + "cancel": "取消", + "close": "關閉" + }, + "launcher": { + "title": "在 Worktree 中建立工作階段", + "description": "從不可變的 Git 提交建立隔離執行目錄,主專案目錄不會被修改。", + "mode": "工作階段模式", + "codeMode": "Code", + "coworkMode": "Cowork", + "baseRef": "基準分支或參照", + "baseRefHint": "可輸入分支、標籤或提交。", + "resolvedCommit": "已解析提交:{{commit}}", + "targetPath": "目標路徑", + "copyChanges": "複製本機變更", + "copyChangesRequiresHead": "僅當基線解析到來源 Worktree 的 HEAD 時才能複製。", + "copyChangesSummary": "共 {{count}} 個檔案:{{staged}} 個已暫存、{{unstaged}} 個未暫存、{{untracked}} 個未追蹤;會保留暫存狀態。", + "checking": "正在檢查儲存庫…", + "remoteUnsupported": "遠端 Workspace 暫不支援 Worktree。", + "notGitRepository": "目前 Workspace 不是 Git 儲存庫。", + "unbornRepository": "請先建立儲存庫的第一個提交,再使用 Worktree。", + "invalidBaseRef": "無法解析所選分支或參照。", + "create": "在 Worktree 中建立" + }, + "sidebar": { + "worktrees": "Worktrees", + "loadFailed": "無法載入 Worktree,點選重試。", + "recreateRequired": "請先重建這個遺失的 Worktree。", + "newSession": "在此 Worktree 建立工作階段" + }, + "labels": { + "detached": "游離 {{commit}}", + "dirty": "有變更", + "missing": "路徑遺失", + "running": "{{count}} 個執行中", + "unpublished": "有未發佈提交", + "lifecycle": { + "managed": "代管", + "permanent": "永久保留", + "external": "外部" + } + }, + "strip": { + "menuLabel": "Worktree 操作", + "open": "開啟 Worktree 資料夾" + }, + "manager": { + "title": "Worktree 管理器", + "description": "集中管理目前專案的隔離工作階段目錄。", + "refresh": "重新整理", + "create": "建立 Worktree 工作階段", + "loading": "正在載入 Worktree…", + "emptyTitle": "尚無 Worktree", + "emptyDescription": "建立隔離工作階段即可平行工作,並保持主目錄不變。", + "sessionCount": "關聯 {{count}} 個工作階段", + "open": "開啟", + "newSession": "建立工作階段", + "createBranch": "建立分支", + "recreate": "重建", + "keep": "永久保留", + "remove": "刪除", + "recreated": "Worktree 已重建", + "promoted": "Worktree 已永久保留", + "branchCreated": "分支已建立", + "removed": "Worktree 已刪除", + "risks": { + "dirty": "目錄中存在本機變更。", + "unpublished": "游離 HEAD 包含未被其他參照包含的提交。", + "sessions": "仍關聯 {{count}} 個工作階段。", + "running": "有 {{count}} 個關聯工作階段正在執行或尚未封存。", + "clean": "目錄乾淨,且沒有未發佈提交。" + }, + "branchDialog": { + "title": "建立分支", + "description": "將這個游離 Worktree 綁定到可發佈的分支。", + "required": "請輸入分支名稱。" + }, + "removeDialog": { + "title": "刪除 Worktree?", + "message": "刪除目錄前請檢查以下 Worktree 狀態。", + "blocked": "仍有執行中或未封存的關聯工作階段,不能刪除這個 Worktree。", + "forceTitle": "強制刪除 Worktree?", + "forceMessage": "這會永久丟棄列出的本機工作,請再次確認。", + "forceConfirm": "強制刪除" + } + }, + "settings": { + "title": "Worktrees", + "description": "設定本機隔離工作階段的預設值。修改根目錄只影響之後建立的 Worktree。", + "loading": "正在載入 Worktree 設定…", + "loadFailed": "無法載入 Worktree 設定。", + "saveFailed": "無法儲存 Worktree 設定。", + "saved": "Worktree 設定已儲存。", + "required": "Worktree 根目錄和分支前綴不能為空。", + "save": "儲存", + "reset": "恢復預設值", + "creation": { + "title": "工作階段建立", + "description": "Local 維持為快速、安全的預設路徑;新工作階段選單始終提供 Worktree 隔離選項。" + }, + "defaultTarget": { + "label": "預設新工作階段目標", + "description": "控制預選目標;Code 和 Cowork 快捷按鈕仍直接建立 Local 工作階段。", + "local": "Local", + "worktree": "代管 Worktree" + }, + "rootPath": { + "label": "Worktree 根目錄", + "description": "之後建立的代管 Worktree 會存放在此目錄下。" + }, + "branchPrefix": { + "label": "分支前綴", + "description": "為游離 Worktree 建立分支時使用的建議前綴。" + }, + "copyChanges": { + "label": "預設複製本機變更", + "description": "預設關閉;僅當所選基準與來源目錄 HEAD 相同時可用。" + } + } +} diff --git a/src/web-ui/src/shared/services/worktreeUIEvents.ts b/src/web-ui/src/shared/services/worktreeUIEvents.ts new file mode 100644 index 0000000000..a862281928 --- /dev/null +++ b/src/web-ui/src/shared/services/worktreeUIEvents.ts @@ -0,0 +1,19 @@ +export const OPEN_WORKTREE_MANAGER_EVENT = 'bitfun:open-worktree-manager'; +export const OPEN_WORKTREE_LAUNCHER_EVENT = 'bitfun:open-worktree-launcher'; + +export type WorktreeLauncherMode = 'agentic' | 'Cowork'; + +export function openWorktreeManager(projectWorkspacePath: string): void { + window.dispatchEvent(new CustomEvent(OPEN_WORKTREE_MANAGER_EVENT, { + detail: { projectWorkspacePath }, + })); +} + +export function openWorktreeLauncher( + projectWorkspacePath: string, + mode: WorktreeLauncherMode = 'agentic', +): void { + window.dispatchEvent(new CustomEvent(OPEN_WORKTREE_LAUNCHER_EVENT, { + detail: { projectWorkspacePath, mode }, + })); +} diff --git a/src/web-ui/src/shared/types/session-history.ts b/src/web-ui/src/shared/types/session-history.ts index 8ac79abdbe..1b83924d38 100644 --- a/src/web-ui/src/shared/types/session-history.ts +++ b/src/web-ui/src/shared/types/session-history.ts @@ -6,6 +6,7 @@ import type { ReviewTargetEvidence, ReviewTeamRunManifest } from '@/shared/services/reviewTeamService'; import type { AiErrorDetail } from '@/shared/ai-errors/aiErrorPresenter'; +import type { SessionExecutionTarget } from '@/infrastructure/api/service-api/WorktreeAPI'; export type SessionKind = 'normal' | 'btw' | 'review' | 'deep_review' | 'miniapp' | 'subagent'; export type PersistedSessionKind = 'standard' | 'subagent'; @@ -77,6 +78,8 @@ export interface SessionMetadata { relationship?: SessionRelationship; todos?: any[]; workspacePath?: string; + projectWorkspacePath?: string; + executionTarget?: SessionExecutionTarget; remoteConnectionId?: string; remoteSshHost?: string; /** Backend unified workspace identity field: localhost for local, SSH host for remote. */ diff --git a/tests/e2e/config/embedded-driver.ts b/tests/e2e/config/embedded-driver.ts index 11887438ef..3d43d0d7ac 100644 --- a/tests/e2e/config/embedded-driver.ts +++ b/tests/e2e/config/embedded-driver.ts @@ -474,6 +474,7 @@ function sharedAfterTest(): Options.Testrunner['afterTest'] { try { const screenshotPath = path.resolve(__dirname, '..', 'reports', 'screenshots', screenshotName); + fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); await browser.saveScreenshot(screenshotPath); console.log(`Screenshot saved: ${screenshotName}`); } catch (screenshotError) { @@ -514,6 +515,17 @@ export function createEmbeddedConfig(specs: string[], label: string): Options.Te hostname: DRIVER_HOST, port: DRIVER_PORT, path: '/', + // Node 26's built-in Undici rejects the hop-by-hop `Connection` header + // that WebDriverIO 9 adds by default. The embedded driver is a local HTTP + // endpoint and does not need callers to manage connection persistence. + transformRequest: (requestOptions) => { + const headers = requestOptions.headers as { + delete?: (name: string) => void; + } | undefined; + headers?.delete?.('Connection'); + headers?.delete?.('Content-Length'); + return requestOptions; + }, framework: 'mocha', reporters: ['spec'], diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 004b4554c2..dcdb0105a8 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -29,6 +29,8 @@ "test:l1:session": "wdio run ./config/wdio.conf.ts --spec \"./specs/l1-session.spec.ts\"", "test:l1:dialog": "wdio run ./config/wdio.conf.ts --spec \"./specs/l1-dialog.spec.ts\"", "test:l1:chat-flow": "wdio run ./config/wdio.conf.ts --spec \"./specs/l1-chat.spec.ts\"", + "test:l1:worktree": "wdio run ./config/wdio.conf.ts --spec \"./specs/l1-worktree.spec.ts\"", + "test:l1:worktree-restart": "node ./scripts/run-worktree-restart-e2e.mjs", "test:l1:browser-video": "wdio run ./config/wdio.conf.ts --spec \"../specs/l1-browser-video-stream.spec.ts\"", "test:perf": "wdio run ./config/wdio.conf.ts --spec \"./specs/performance/startup-session-perf.spec.ts\"", "fixture:long-session": "node ./scripts/generate-long-session-fixture.mjs", diff --git a/tests/e2e/scripts/run-worktree-restart-e2e.mjs b/tests/e2e/scripts/run-worktree-restart-e2e.mjs new file mode 100644 index 0000000000..ad552d1c34 --- /dev/null +++ b/tests/e2e/scripts/run-worktree-restart-e2e.mjs @@ -0,0 +1,97 @@ +import { spawn } from 'child_process'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { randomUUID } from 'crypto'; +import { fileURLToPath } from 'url'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const e2eDirectory = path.resolve(scriptDirectory, '..'); +const manifestPath = path.join( + os.tmpdir(), + `bitfun-worktree-restart-manifest-${process.pid}-${randomUUID()}.json`, +); +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + +function runPhase(phase) { + return new Promise((resolve, reject) => { + const child = spawn( + pnpmCommand, + [ + 'exec', + 'wdio', + 'run', + './config/wdio.conf.ts', + '--spec', + './specs/l1-worktree-restart.spec.ts', + ], + { + cwd: e2eDirectory, + stdio: 'inherit', + env: { + ...process.env, + E2E_LOG_LEVEL: process.env.E2E_LOG_LEVEL || 'warn', + BITFUN_E2E_WORKTREE_RESTART_PHASE: phase, + BITFUN_E2E_WORKTREE_RESTART_MANIFEST: manifestPath, + }, + }, + ); + + child.on('error', reject); + child.on('exit', (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error( + `Worktree restart ${phase} phase failed (code=${code}, signal=${signal ?? 'none'})`, + )); + }); + }); +} + +function cleanupAbandonedFixture() { + if (!fs.existsSync(manifestPath)) { + return; + } + + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const repositoryPath = manifest.repositoryPath; + const worktreePath = manifest.worktree?.path; + if ( + typeof repositoryPath === 'string' + && fs.existsSync(repositoryPath) + && typeof worktreePath === 'string' + && fs.existsSync(worktreePath) + ) { + try { + execFileSync( + 'git', + ['worktree', 'remove', '--force', worktreePath], + { cwd: repositoryPath, stdio: 'ignore' }, + ); + } catch { + // The in-app after hook normally performs this cleanup. + } + } + if ( + typeof manifest.fixtureRoot === 'string' + && path.basename(manifest.fixtureRoot).startsWith( + 'bitfun-worktree-restart-e2e-', + ) + ) { + fs.rmSync(manifest.fixtureRoot, { recursive: true, force: true }); + } + } finally { + fs.rmSync(manifestPath, { force: true }); + } +} + +try { + await runPhase('create'); + await runPhase('verify'); +} finally { + cleanupAbandonedFixture(); +} diff --git a/tests/e2e/specs/l1-worktree-restart.spec.ts b/tests/e2e/specs/l1-worktree-restart.spec.ts new file mode 100644 index 0000000000..2cb47c4f5a --- /dev/null +++ b/tests/e2e/specs/l1-worktree-restart.spec.ts @@ -0,0 +1,340 @@ +/** + * Two-process Worktree persistence verification. + * + * The companion runner executes this spec twice. The create phase leaves a + * test-owned repository and managed Worktree in place, then WDIO terminates + * the Desktop process. The verify phase starts a fresh Desktop process against + * the same isolated E2E profile, verifies reconciliation, and cleans up. + */ + +import { browser, expect, $ } from '@wdio/globals'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { randomUUID } from 'crypto'; +import { waitForWorkspaceReady } from '../helpers/workspace-helper'; + +interface WorktreeSessionSummary { + sessionId: string; + sessionName: string; + status: string; + archived: boolean; +} + +interface WorktreeSummary { + worktreeId: string; + projectWorkspacePath: string; + path: string; + head: string; + branch?: string; + lifecycle: 'managed' | 'permanent' | 'external'; + isMain: boolean; + dirty: boolean; + locked: boolean; + missing: boolean; + hasUnpublishedCommits: boolean; + associatedSessionCount: number; + runningSessionCount: number; + sessions: WorktreeSessionSummary[]; +} + +interface RestartManifest { + fixtureRoot: string; + repositoryPath: string; + baseCommit: string; + worktree: WorktreeSummary; +} + +type InvokeOutcome = + | { ok: true; value: T } + | { ok: false; error: unknown }; + +const phase = process.env.BITFUN_E2E_WORKTREE_RESTART_PHASE; +const manifestPath = process.env.BITFUN_E2E_WORKTREE_RESTART_MANIFEST; + +function git(repositoryPath: string, args: string[]): string { + return execFileSync('git', args, { + cwd: repositoryPath, + encoding: 'utf8', + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + }, + }).trim(); +} + +async function invokeOutcome( + command: string, + request: Record, +): Promise> { + const encoded = await browser.executeAsync( + ( + commandName: string, + commandRequest: Record, + done: (value: string) => void, + ) => { + const tauriWindow = window as typeof window & { + __TAURI__?: { + core?: { + invoke?: ( + command: string, + args?: Record, + ) => Promise; + }; + }; + }; + const invoke = tauriWindow.__TAURI__?.core?.invoke; + if (typeof invoke !== 'function') { + done(JSON.stringify({ + ok: false as const, + error: { message: 'Tauri invoke is unavailable' }, + })); + return; + } + + invoke(commandName, { request: commandRequest }).then(value => { + done(JSON.stringify({ ok: true as const, value })); + }, error => { + let normalizedError: unknown; + if (typeof error === 'string') { + try { + normalizedError = JSON.parse(error); + } catch { + normalizedError = { message: error }; + } + } else if (error && typeof error === 'object') { + try { + normalizedError = JSON.parse(JSON.stringify(error)); + } catch { + normalizedError = { message: String(error) }; + } + } else { + normalizedError = { message: String(error) }; + } + done(JSON.stringify({ ok: false as const, error: normalizedError })); + }); + }, + command, + request, + ); + return JSON.parse(encoded as string) as InvokeOutcome; +} + +async function invoke( + command: string, + request: Record, +): Promise { + const outcome = await invokeOutcome(command, request); + if (!outcome.ok) { + throw new Error(`${command} failed: ${JSON.stringify(outcome.error)}`); + } + return outcome.value; +} + +async function openWorkspace(workspacePath: string): Promise { + await browser.execute(async (targetWorkspacePath: string) => { + const { workspaceManager } = await import( + '/src/infrastructure/services/business/workspaceManager.ts' + ); + await workspaceManager.openWorkspace(targetWorkspacePath); + }, workspacePath); +} + +async function cleanupFixture(manifest: Partial): Promise { + const repositoryPath = manifest.repositoryPath; + if (repositoryPath && fs.existsSync(repositoryPath)) { + let worktrees: WorktreeSummary[] = manifest.worktree ? [manifest.worktree] : []; + try { + worktrees = (await invoke('worktree_list', { + projectWorkspacePath: repositoryPath, + })).filter(worktree => !worktree.isMain); + } catch { + // Fall back to the manifest if Desktop reconciliation is unavailable. + } + + for (const worktree of worktrees) { + for (const session of worktree.sessions ?? []) { + await invokeOutcome('archive_session', { + session_id: session.sessionId, + workspace_path: repositoryPath, + }); + await invokeOutcome('delete_session', { + sessionId: session.sessionId, + workspacePath: repositoryPath, + }); + } + const removed = await invokeOutcome('worktree_remove', { + requestId: randomUUID(), + projectWorkspacePath: repositoryPath, + worktreeId: worktree.worktreeId, + force: true, + }); + if (!removed.ok && fs.existsSync(worktree.path)) { + try { + git(repositoryPath, ['worktree', 'remove', '--force', worktree.path]); + } catch { + // The fixture cleanup below remains limited to test-owned paths. + } + } + } + + try { + git(repositoryPath, ['worktree', 'prune']); + } catch { + // The repository may already have been removed by a failed setup. + } + } + + if ( + manifest.fixtureRoot + && path.basename(manifest.fixtureRoot).startsWith('bitfun-worktree-restart-e2e-') + ) { + fs.rmSync(manifest.fixtureRoot, { recursive: true, force: true }); + } +} + +describe('L1 managed Worktree desktop restart recovery', () => { + if (!manifestPath) { + it('requires a runner-provided manifest path', () => { + throw new Error('BITFUN_E2E_WORKTREE_RESTART_MANIFEST is required'); + }); + return; + } + + if (phase === 'create') { + let pendingManifest: Partial = {}; + let handoffReady = false; + + it('persists a managed Worktree and session before Desktop exits', async () => { + const fixtureRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'bitfun-worktree-restart-e2e-'), + ); + const createdRepositoryPath = path.join(fixtureRoot, 'repository'); + fs.mkdirSync(createdRepositoryPath); + const repositoryPath = fs.realpathSync(createdRepositoryPath); + pendingManifest = { fixtureRoot, repositoryPath }; + + git(repositoryPath, ['init']); + git(repositoryPath, ['config', 'user.name', 'BitFun E2E']); + git(repositoryPath, ['config', 'user.email', 'bitfun-e2e@example.invalid']); + fs.writeFileSync(path.join(repositoryPath, 'shared.txt'), 'restart baseline\n'); + git(repositoryPath, ['add', 'shared.txt']); + git(repositoryPath, ['commit', '-m', 'restart baseline']); + const baseCommit = git(repositoryPath, ['rev-parse', 'HEAD']); + + await openWorkspace(repositoryPath); + await waitForWorkspaceReady(repositoryPath, path.basename(repositoryPath)); + + const launcherButton = await $('[data-testid="nav-new-worktree-session-btn"]'); + await launcherButton.waitForClickable({ timeout: 15000 }); + await launcherButton.click(); + const launcher = await $('[data-testid="worktree-launcher"]'); + await launcher.waitForDisplayed({ timeout: 10000 }); + const createButton = await launcher.$( + '.bitfun-worktree-launcher__footer button:last-child', + ); + await browser.waitUntil(() => createButton.isEnabled(), { + timeout: 15000, + interval: 200, + timeoutMsg: 'Worktree launcher did not become ready', + }); + await createButton.click(); + await launcher.waitForDisplayed({ reverse: true, timeout: 30000 }); + + const worktrees = await invoke('worktree_list', { + projectWorkspacePath: repositoryPath, + }); + const worktree = worktrees.find(candidate => !candidate.isMain); + expect(worktree).toBeDefined(); + expect(worktree?.head).toBe(baseCommit); + expect(worktree?.sessions).toHaveLength(1); + + const manifest: RestartManifest = { + fixtureRoot, + repositoryPath, + baseCommit, + worktree: worktree as WorktreeSummary, + }; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), { + encoding: 'utf8', + flag: 'wx', + }); + pendingManifest = manifest; + handoffReady = true; + }); + + after(async () => { + if (!handoffReady) { + await cleanupFixture(pendingManifest); + fs.rmSync(manifestPath, { force: true }); + } + }); + return; + } + + if (phase === 'verify') { + let manifest: RestartManifest; + + before(() => { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as RestartManifest; + }); + + it('recovers the Worktree registry, UI group, and session binding in a new process', async () => { + await waitForWorkspaceReady( + manifest.repositoryPath, + path.basename(manifest.repositoryPath), + 20000, + ); + + const worktrees = await invoke('worktree_list', { + projectWorkspacePath: manifest.repositoryPath, + }); + const restored = worktrees.find( + worktree => worktree.worktreeId === manifest.worktree.worktreeId, + ); + expect(restored).toBeDefined(); + expect(restored?.path).toBe(manifest.worktree.path); + expect(restored?.head).toBe(manifest.baseCommit); + expect(restored?.lifecycle).toBe('managed'); + expect(restored?.missing).toBe(false); + expect(restored?.sessions).toHaveLength(1); + expect(restored?.sessions[0].sessionId).toBe( + manifest.worktree.sessions[0].sessionId, + ); + + const metadata = await invoke | null>( + 'load_persisted_session_metadata', + { + session_id: manifest.worktree.sessions[0].sessionId, + workspace_path: manifest.repositoryPath, + }, + ); + expect(metadata?.workspacePath).toBe(manifest.worktree.path); + expect(metadata?.projectWorkspacePath).toBe(manifest.repositoryPath); + + await browser.waitUntil(async () => browser.execute( + (worktreeId: string) => Boolean( + document.querySelector(`[data-worktree-id="${worktreeId}"]`), + ), + manifest.worktree.worktreeId, + ), { + timeout: 20000, + interval: 250, + timeoutMsg: 'Recovered Worktree group was not rendered after Desktop restart', + }); + }); + + after(async () => { + await cleanupFixture(manifest); + fs.rmSync(manifestPath, { force: true }); + }); + return; + } + + it('requires a valid restart phase', () => { + throw new Error( + 'BITFUN_E2E_WORKTREE_RESTART_PHASE must be "create" or "verify"', + ); + }); +}); diff --git a/tests/e2e/specs/l1-worktree.spec.ts b/tests/e2e/specs/l1-worktree.spec.ts new file mode 100644 index 0000000000..c7a070c273 --- /dev/null +++ b/tests/e2e/specs/l1-worktree.spec.ts @@ -0,0 +1,397 @@ +/** + * L1 managed Worktree workflow. + * + * Exercises the real desktop UI, Tauri commands, Git worktrees, session + * persistence, and removal guards against a temporary repository. + */ + +import { browser, expect, $ } from '@wdio/globals'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { randomUUID } from 'crypto'; +import { waitForWorkspaceReady } from '../helpers/workspace-helper'; + +interface WorktreeSessionSummary { + sessionId: string; + sessionName: string; + status: string; + archived: boolean; +} + +interface WorktreeSummary { + worktreeId: string; + projectWorkspacePath: string; + path: string; + head: string; + branch?: string; + lifecycle: 'managed' | 'permanent' | 'external'; + isMain: boolean; + dirty: boolean; + locked: boolean; + missing: boolean; + hasUnpublishedCommits: boolean; + associatedSessionCount: number; + runningSessionCount: number; + sessions: WorktreeSessionSummary[]; +} + +interface ToolExecutionResponse { + tool_name: string; + success: boolean; + result?: { + success?: boolean; + operation?: string; + worktree_id?: string; + path?: string; + session_id?: string; + }; + error?: string; + validation_error?: string; +} + +type InvokeOutcome = + | { ok: true; value: T } + | { ok: false; error: unknown }; + +function git(repositoryPath: string, args: string[]): string { + return execFileSync('git', args, { + cwd: repositoryPath, + encoding: 'utf8', + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + }, + }).trim(); +} + +async function invokeOutcome( + command: string, + request: Record, +): Promise> { + const encoded = await browser.executeAsync( + ( + commandName: string, + commandRequest: Record, + done: (value: string) => void, + ) => { + const tauriWindow = window as typeof window & { + __TAURI__?: { + core?: { + invoke?: ( + command: string, + args?: Record, + ) => Promise; + }; + }; + }; + const invoke = tauriWindow.__TAURI__?.core?.invoke; + if (typeof invoke !== 'function') { + done(JSON.stringify({ + ok: false as const, + error: { message: 'Tauri invoke is unavailable' }, + })); + return; + } + + invoke(commandName, { request: commandRequest }).then(value => { + done(JSON.stringify({ ok: true as const, value })); + }, error => { + let normalizedError: unknown; + if (typeof error === 'string') { + try { + normalizedError = JSON.parse(error); + } catch { + normalizedError = { message: error }; + } + } else if (error && typeof error === 'object') { + try { + normalizedError = JSON.parse(JSON.stringify(error)); + } catch { + normalizedError = { message: String(error) }; + } + } else { + normalizedError = { message: String(error) }; + } + done(JSON.stringify({ ok: false as const, error: normalizedError })); + }); + }, + command, + request, + ); + return JSON.parse(encoded as string) as InvokeOutcome; +} + +async function invoke( + command: string, + request: Record, +): Promise { + const outcome = await invokeOutcome(command, request); + if (!outcome.ok) { + throw new Error(`${command} failed: ${JSON.stringify(outcome.error)}`); + } + return outcome.value; +} + +async function openWorkspace(workspacePath: string): Promise { + await browser.execute(async (targetWorkspacePath: string) => { + const { workspaceManager } = await import( + '/src/infrastructure/services/business/workspaceManager.ts' + ); + await workspaceManager.openWorkspace(targetWorkspacePath); + }, workspacePath); +} + +function errorCode(outcome: InvokeOutcome): string | undefined { + if (outcome.ok || !outcome.error || typeof outcome.error !== 'object') { + return undefined; + } + return (outcome.error as { code?: string }).code; +} + +describe('L1 managed Worktree workflow', () => { + let fixtureRoot = ''; + let repositoryPath = ''; + let baseCommit = ''; + let createdWorktrees: WorktreeSummary[] = []; + + before(async () => { + fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfun-worktree-e2e-')); + repositoryPath = path.join(fixtureRoot, 'repository'); + fs.mkdirSync(repositoryPath); + // Desktop canonicalizes local workspace paths. On macOS, os.tmpdir() + // commonly resolves through /var -> /private/var, so use the canonical + // path for all frontend/backend identity assertions. + repositoryPath = fs.realpathSync(repositoryPath); + git(repositoryPath, ['init']); + git(repositoryPath, ['config', 'user.name', 'BitFun E2E']); + git(repositoryPath, ['config', 'user.email', 'bitfun-e2e@example.invalid']); + fs.writeFileSync(path.join(repositoryPath, 'shared.txt'), 'base\n'); + git(repositoryPath, ['add', 'shared.txt']); + git(repositoryPath, ['commit', '-m', 'base']); + baseCommit = git(repositoryPath, ['rev-parse', 'HEAD']); + + await openWorkspace(repositoryPath); + await waitForWorkspaceReady(repositoryPath, path.basename(repositoryPath)); + }); + + it('creates two isolated sessions from the same baseline through the UI', async () => { + for (let expectedCount = 1; expectedCount <= 2; expectedCount += 1) { + const launcherButton = await $('[data-testid="nav-new-worktree-session-btn"]'); + await launcherButton.waitForClickable({ timeout: 15000 }); + await launcherButton.click(); + + const launcher = await $('[data-testid="worktree-launcher"]'); + await launcher.waitForDisplayed({ timeout: 10000 }); + const createButton = await launcher.$( + '.bitfun-worktree-launcher__footer button:last-child', + ); + await browser.waitUntil(() => createButton.isEnabled(), { + timeout: 15000, + interval: 200, + timeoutMsg: 'Worktree launcher did not become ready', + }); + await createButton.click(); + await launcher.waitForDisplayed({ reverse: true, timeout: 30000 }); + + await browser.waitUntil(async () => { + const worktrees = await invoke('worktree_list', { + projectWorkspacePath: repositoryPath, + }); + return worktrees.filter(worktree => !worktree.isMain).length === expectedCount; + }, { + timeout: 30000, + interval: 250, + timeoutMsg: `Expected ${expectedCount} managed Worktree(s)`, + }); + } + + const worktrees = await invoke('worktree_list', { + projectWorkspacePath: repositoryPath, + }); + createdWorktrees = worktrees.filter(worktree => !worktree.isMain); + + expect(createdWorktrees).toHaveLength(2); + expect(new Set(createdWorktrees.map(worktree => worktree.path)).size).toBe(2); + for (const worktree of createdWorktrees) { + expect(worktree.head).toBe(baseCommit); + expect(worktree.branch).toBeUndefined(); + expect(worktree.lifecycle).toBe('managed'); + expect(worktree.missing).toBe(false); + expect(worktree.sessions).toHaveLength(1); + } + }); + + it('keeps parallel file changes isolated from each other and the main checkout', () => { + const [first, second] = createdWorktrees; + fs.writeFileSync(path.join(first.path, 'shared.txt'), 'first worktree\n'); + fs.writeFileSync(path.join(second.path, 'shared.txt'), 'second worktree\n'); + + expect(fs.readFileSync(path.join(repositoryPath, 'shared.txt'), 'utf8')).toBe('base\n'); + expect(fs.readFileSync(path.join(first.path, 'shared.txt'), 'utf8')).toBe( + 'first worktree\n', + ); + expect(fs.readFileSync(path.join(second.path, 'shared.txt'), 'utf8')).toBe( + 'second worktree\n', + ); + }); + + it('lets the deferred Agent Worktree tool create an isolated child session', async () => { + const response = await invoke('execute_tool', { + toolName: 'Worktree', + input: { + operation: 'create_session', + base_ref: 'HEAD', + copy_local_changes: false, + session_name: 'Agent-created Worktree session', + agent_type: 'agentic', + }, + workspacePath: repositoryPath, + context: null, + safeMode: false, + }); + + expect(response.success).toBe(true); + expect(response.error).toBeNull(); + expect(response.validation_error).toBeNull(); + expect(response.result?.success).toBe(true); + expect(response.result?.operation).toBe('create_session'); + expect(response.result?.worktree_id).toBeTruthy(); + expect(response.result?.session_id).toBeTruthy(); + + const worktrees = await invoke('worktree_list', { + projectWorkspacePath: repositoryPath, + }); + const created = worktrees.find( + worktree => worktree.worktreeId === response.result?.worktree_id, + ); + expect(created).toBeDefined(); + expect(created?.path).toBe(response.result?.path); + expect(created?.head).toBe(baseCommit); + expect(created?.lifecycle).toBe('managed'); + expect(created?.sessions[0]?.sessionId).toBe(response.result?.session_id); + createdWorktrees.push(created as WorktreeSummary); + }); + + it('restores project grouping and session bindings after a frontend reload', async () => { + await browser.refresh(); + await waitForWorkspaceReady(repositoryPath, path.basename(repositoryPath)); + + const worktreeIds = new Set(createdWorktrees.map(worktree => worktree.worktreeId)); + await browser.waitUntil(async () => { + const renderedIds = await browser.execute(() => ( + Array.from(document.querySelectorAll('[data-worktree-id]')) + .map(element => element.getAttribute('data-worktree-id')) + .filter((value): value is string => Boolean(value)) + )); + return createdWorktrees.every(worktree => renderedIds.includes(worktree.worktreeId)); + }, { + timeout: 20000, + interval: 250, + timeoutMsg: 'Worktree groups were not restored after reload', + }); + + const reconciled = await invoke('worktree_list', { + projectWorkspacePath: repositoryPath, + }); + const restored = reconciled.filter(worktree => worktreeIds.has(worktree.worktreeId)); + expect(restored).toHaveLength(createdWorktrees.length); + for (const worktree of restored) { + expect(worktree.sessions).toHaveLength(1); + const metadata = await invoke | null>( + 'load_persisted_session_metadata', + { + session_id: worktree.sessions[0].sessionId, + workspace_path: repositoryPath, + }, + ); + expect(metadata).not.toBeNull(); + expect(metadata?.workspacePath).toBe(worktree.path); + expect(metadata?.projectWorkspacePath).toBe(repositoryPath); + } + }); + + it('blocks safe removal for dirty and unpublished detached worktrees', async () => { + for (const worktree of createdWorktrees) { + await invoke('archive_session', { + session_id: worktree.sessions[0].sessionId, + workspace_path: repositoryPath, + }); + } + + const [dirtyWorktree, unpublishedWorktree] = createdWorktrees; + git(unpublishedWorktree.path, ['add', 'shared.txt']); + git(unpublishedWorktree.path, ['commit', '-m', 'detached unpublished change']); + + const dirtyRemoval = await invokeOutcome('worktree_remove', { + requestId: randomUUID(), + projectWorkspacePath: repositoryPath, + worktreeId: dirtyWorktree.worktreeId, + force: false, + }); + const unpublishedRemoval = await invokeOutcome('worktree_remove', { + requestId: randomUUID(), + projectWorkspacePath: repositoryPath, + worktreeId: unpublishedWorktree.worktreeId, + force: false, + }); + + expect(dirtyRemoval.ok).toBe(false); + expect(errorCode(dirtyRemoval)).toBe('dirty_worktree'); + expect(unpublishedRemoval.ok).toBe(false); + expect(errorCode(unpublishedRemoval)).toBe('unpublished_commits'); + }); + + after(async () => { + if (repositoryPath) { + let worktrees = createdWorktrees; + try { + worktrees = (await invoke('worktree_list', { + projectWorkspacePath: repositoryPath, + })).filter(worktree => !worktree.isMain); + } catch { + // Fall back to the last successfully reconciled list. + } + + for (const worktree of worktrees) { + for (const session of worktree.sessions) { + await invokeOutcome('archive_session', { + session_id: session.sessionId, + workspace_path: repositoryPath, + }); + await invokeOutcome('delete_session', { + sessionId: session.sessionId, + workspacePath: repositoryPath, + }); + } + const removed = await invokeOutcome('worktree_remove', { + requestId: randomUUID(), + projectWorkspacePath: repositoryPath, + worktreeId: worktree.worktreeId, + force: true, + }); + if (!removed.ok && fs.existsSync(worktree.path)) { + try { + git(repositoryPath, ['worktree', 'remove', '--force', worktree.path]); + } catch { + // The fixture root cleanup below is limited to this test's directory. + } + } + } + + try { + git(repositoryPath, ['worktree', 'prune']); + } catch { + // Repository cleanup below is sufficient if Git is already unavailable. + } + } + + if ( + fixtureRoot + && path.basename(fixtureRoot).startsWith('bitfun-worktree-e2e-') + ) { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); +}); From 5c9c908658594b7c00ecb33a3e18e3eebaa4ce32 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 26 Jul 2026 23:29:26 -0700 Subject: [PATCH 2/3] fix(worktrees): use canonical theme tokens --- .../NavPanel/sections/workspaces/ProjectWorktrees.scss | 6 +++--- .../NavPanel/sections/workspaces/WorktreeLauncherModal.scss | 4 ++-- .../NavPanel/sections/workspaces/WorktreeManagerModal.scss | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/ProjectWorktrees.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/ProjectWorktrees.scss index c2d66df7b2..aebe766e1b 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/ProjectWorktrees.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/ProjectWorktrees.scss @@ -14,7 +14,7 @@ .bitfun-project-worktrees__heading { justify-content: space-between; padding: 3px 4px; - color: var(--color-text-tertiary); + color: var(--color-text-muted); font-size: 10px; font-weight: 600; letter-spacing: 0.04em; @@ -42,7 +42,7 @@ .bitfun-project-worktrees__heading > button:hover, .bitfun-project-worktrees__new-session:hover:not(:disabled) { - background: var(--color-bg-hover); + background: var(--element-bg-hover); color: var(--color-text-primary); } @@ -91,7 +91,7 @@ padding: 1px 4px; border-radius: 8px; background: var(--color-bg-tertiary); - color: var(--color-text-tertiary); + color: var(--color-text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.scss index 0872e25aea..5c956cfd6a 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeLauncherModal.scss @@ -29,7 +29,7 @@ .bitfun-worktree-launcher__path { overflow: hidden; padding: 8px 10px; - border: 1px solid var(--color-border); + border: 1px solid var(--border-base); border-radius: 6px; background: var(--color-bg-secondary); color: var(--color-text-secondary); @@ -40,7 +40,7 @@ .bitfun-worktree-launcher__changes { padding: 10px; - border: 1px solid var(--color-border); + border: 1px solid var(--border-base); border-radius: 8px; background: var(--color-bg-secondary); } diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.scss index a68866a05d..25a2c2f4d1 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorktreeManagerModal.scss @@ -35,7 +35,7 @@ justify-content: center; gap: 8px; padding: 24px; - border: 1px dashed var(--color-border); + border: 1px dashed var(--border-base); border-radius: 8px; color: var(--color-text-secondary); font-size: 12px; @@ -63,7 +63,7 @@ justify-content: space-between; gap: 12px; padding: 12px; - border: 1px solid var(--color-border); + border: 1px solid var(--border-base); border-radius: 8px; background: var(--color-bg-secondary); } From a4839b605122598df35fb2e9c23938ec2b7683a6 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 26 Jul 2026 23:50:37 -0700 Subject: [PATCH 3/3] test(worktrees): make path assertions portable --- .../tools/implementations/session_control_tool.rs | 10 ++++++---- .../tools/implementations/session_message_tool.rs | 8 +++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index c57f3cde62..7f7bc76e58 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -667,6 +667,8 @@ mod tests { #[test] fn worktree_context_keeps_project_scope_for_session_operations() { + let worktree_path = PathBuf::from("/worktrees/wt-1"); + let project_path = PathBuf::from("/repo"); let execution_target = SessionExecutionTarget { kind: SessionExecutionTargetKind::ManagedWorktree, worktree_id: Some("wt-1".to_string()), @@ -676,14 +678,14 @@ mod tests { branch: None, lifecycle: Some(WorktreeLifecycle::Managed), }; - let binding = WorkspaceBinding::new(None, PathBuf::from("/worktrees/wt-1")) - .with_project_root_path(PathBuf::from("/repo")) + let binding = WorkspaceBinding::new(None, worktree_path.clone()) + .with_project_root_path(project_path.clone()) .with_execution_target(Some(execution_target.clone())); let target = SessionControlTool::workspace_target_from_context(&binding); - assert_eq!(target.display_workspace, "/worktrees/wt-1"); - assert_eq!(target.project_workspace, "/repo"); + assert_eq!(PathBuf::from(target.display_workspace), worktree_path); + assert_eq!(PathBuf::from(target.project_workspace), project_path); assert_eq!(target.execution_target, Some(execution_target)); } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index 4b726628fb..a4d4cf636a 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -809,6 +809,8 @@ mod tests { #[test] fn creating_in_current_worktree_inherits_project_scope_and_target() { + let worktree_path = PathBuf::from("/worktrees/wt-1"); + let project_path = PathBuf::from("/repo"); let execution_target = SessionExecutionTarget { kind: SessionExecutionTargetKind::ManagedWorktree, worktree_id: Some("wt-1".to_string()), @@ -818,8 +820,8 @@ mod tests { branch: None, lifecycle: Some(WorktreeLifecycle::Managed), }; - let binding = WorkspaceBinding::new(None, PathBuf::from("/worktrees/wt-1")) - .with_project_root_path(PathBuf::from("/repo")) + let binding = WorkspaceBinding::new(None, worktree_path) + .with_project_root_path(project_path.clone()) .with_execution_target(Some(execution_target.clone())); let mut context = empty_context(); context.workspace = Some(binding); @@ -828,7 +830,7 @@ mod tests { .workspace_target_from_context("/worktrees/wt-1".to_string(), &context); assert_eq!(target.workspace_path, "/worktrees/wt-1"); - assert_eq!(target.project_workspace_path, "/repo"); + assert_eq!(PathBuf::from(target.project_workspace_path), project_path); assert_eq!(target.execution_target, Some(execution_target)); }