From f8e9b229d754b0d006b0e7d1b70edc0b1b9fe593 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 28 Jul 2026 18:25:45 -0700 Subject: [PATCH 1/2] feat(worktree): add automatic cleanup and management UI --- .../src/api/remote_workspace_policy.rs | 1 + src/apps/desktop/src/api/worktree_api.rs | 13 +- src/apps/desktop/src/lib.rs | 1 + .../assembly/core/src/service/worktree/mod.rs | 287 ++++++++++- .../contracts/core-types/src/worktree.rs | 26 +- .../settings/settingsTabSearchContent.ts | 4 + .../api/adapters/peer-device-adapter.ts | 2 + .../api/service-api/WorktreeAPI.test.ts | 10 + .../api/service-api/WorktreeAPI.ts | 11 + .../config/components/WorktreesConfig.scss | 238 ++++++++- .../components/WorktreesConfig.test.tsx | 331 ++++++++++++ .../config/components/WorktreesConfig.tsx | 480 ++++++++++++++++-- src/web-ui/src/locales/en-US/worktrees.json | 68 +++ src/web-ui/src/locales/zh-CN/worktrees.json | 68 +++ src/web-ui/src/locales/zh-TW/worktrees.json | 68 +++ 15 files changed, 1555 insertions(+), 53 deletions(-) create mode 100644 src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index e2f47b6248..ae36b3f7b2 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1809,6 +1809,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::RemoteUnsupported, ), ("worktree_list", RemoteWorkspacePolicy::RemoteUnsupported), + ("worktree_list_projects", RemoteWorkspacePolicy::LocalOnly), ("worktree_promote", RemoteWorkspacePolicy::RemoteUnsupported), ( "worktree_recreate", diff --git a/src/apps/desktop/src/api/worktree_api.rs b/src/apps/desktop/src/api/worktree_api.rs index 59d3a1c479..75a05a9fed 100644 --- a/src/apps/desktop/src/api/worktree_api.rs +++ b/src/apps/desktop/src/api/worktree_api.rs @@ -3,9 +3,9 @@ use bitfun_core::service::remote_ssh::lookup_remote_connection; use bitfun_core::service::worktree::{ WorktreeCreateBranchRequest, WorktreeCreateRequest, WorktreeCreateResult, WorktreeListRequest, - WorktreeMutationResult, WorktreePromoteRequest, WorktreeRecreateRequest, WorktreeRemoveRequest, - WorktreeRemoveResult, WorktreeService, WorktreeSessionBindingRequest, - WorktreeSessionBindingResult, + WorktreeMutationResult, WorktreeProjectListRequest, WorktreeProjectSummary, + WorktreePromoteRequest, WorktreeRecreateRequest, WorktreeRemoveRequest, WorktreeRemoveResult, + WorktreeService, WorktreeSessionBindingRequest, WorktreeSessionBindingResult, }; use bitfun_core_types::{WorktreeError, WorktreeErrorCode, WorktreeSummary}; @@ -36,6 +36,13 @@ pub async fn worktree_list( WorktreeService::list(request).await } +#[tauri::command] +pub async fn worktree_list_projects( + request: WorktreeProjectListRequest, +) -> Result, WorktreeError> { + WorktreeService::list_projects(request).await +} + #[tauri::command] pub async fn worktree_create( request: WorktreeCreateRequest, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 248f0d84fb..ed5df58ffa 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1355,6 +1355,7 @@ pub async fn run() { git_add_worktree, git_remove_worktree, api::worktree_api::worktree_list, + api::worktree_api::worktree_list_projects, api::worktree_api::worktree_create, api::worktree_api::worktree_create_branch, api::worktree_api::worktree_promote, diff --git a/src/crates/assembly/core/src/service/worktree/mod.rs b/src/crates/assembly/core/src/service/worktree/mod.rs index 60d70eff4e..020e91251d 100644 --- a/src/crates/assembly/core/src/service/worktree/mod.rs +++ b/src/crates/assembly/core/src/service/worktree/mod.rs @@ -9,7 +9,7 @@ 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, + get_global_workspace_service, WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceKind, }; use crate::service::workspace_runtime::get_workspace_runtime_service_arc; use bitfun_core_types::{ @@ -30,6 +30,7 @@ const WORKTREE_REGISTRY_VERSION: u32 = 1; const REGISTRY_FILE_NAME: &str = "worktrees.json"; const WORKTREE_DIRECTORY_SUFFIX_LENGTH: usize = 8; const WORKTREE_PROJECT_LABEL_MAX_CHARS: usize = 48; +const AUTO_DELETE_MIN_AGE_MS: u64 = 24 * 60 * 60 * 1_000; mod session_binding; @@ -43,6 +44,17 @@ pub struct WorktreeListRequest { pub project_workspace_path: String, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeProjectListRequest {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeProjectSummary { + pub project_workspace_path: String, + pub worktrees: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorktreeCreateRequest { @@ -283,6 +295,51 @@ impl WorktreeService { Ok(summaries) } + /// Lists local Git projects known to the workspace service together with + /// their non-main worktrees. Invalid and non-Git workspace records are + /// ignored so one stale project cannot hide the rest of the catalog. + pub async fn list_projects( + _request: WorktreeProjectListRequest, + ) -> Result, WorktreeError> { + let project_paths = known_project_workspace_paths().await; + let mut projects = Vec::new(); + + for project_path in project_paths { + match Self::list(WorktreeListRequest { + project_workspace_path: path_string(&project_path), + }) + .await + { + Ok(worktrees) => { + let worktrees = worktrees + .into_iter() + .filter(|worktree| !worktree.is_main) + .collect::>(); + if worktrees.is_empty() { + continue; + } + projects.push(WorktreeProjectSummary { + project_workspace_path: path_string(&project_path), + worktrees, + }); + } + Err(list_error) => { + log::warn!( + "Failed to list worktrees for project {}: {}", + project_path.display(), + list_error + ); + } + } + } + + projects.sort_by(|left, right| { + left.project_workspace_path + .cmp(&right.project_workspace_path) + }); + Ok(projects) + } + pub async fn create( request: WorktreeCreateRequest, ) -> Result { @@ -430,6 +487,16 @@ impl WorktreeService { .await); } + if let Err(cleanup_error) = + Self::auto_delete_old_worktrees(&context, &mut registry, &worktree_id).await + { + log::warn!( + "Failed to auto-delete old worktrees for project {}: {}", + context.project_workspace_path.display(), + cleanup_error + ); + } + let result = Self::create_result_for_id(&context, &mut registry, &worktree_id, true).await?; notify_changed(&context.project_workspace_path).await; @@ -1064,6 +1131,144 @@ impl WorktreeService { } } } + + async fn auto_delete_old_worktrees( + context: &RepositoryContext, + registry: &mut WorktreeRegistry, + protected_worktree_id: &str, + ) -> Result { + if !context.settings.auto_delete_enabled { + return Ok(0); + } + + let candidate_ids = automatic_delete_candidate_ids( + registry, + context.settings.auto_delete_limit.max(1), + protected_worktree_id, + current_unix_ms(), + ); + if candidate_ids.is_empty() { + return Ok(0); + } + + let (summaries, reconciled) = Self::reconcile(context, registry).await?; + let summaries_by_id = summaries + .into_iter() + .map(|summary| (summary.worktree_id.clone(), summary)) + .collect::>(); + let mut changed = reconciled; + let mut removed_count = 0; + + for candidate_id in candidate_ids { + let Some(summary) = summaries_by_id.get(&candidate_id) else { + continue; + }; + if let Err(protected_reason) = validate_removal(summary, false) { + log::debug!( + "Skipping automatic worktree deletion for {}: {}", + summary.path, + protected_reason + ); + continue; + } + + if let Err(remove_error) = + GitService::remove_worktree(&context.project_workspace_path, &summary.path, false) + .await + { + log::warn!( + "Failed to automatically remove worktree {}: {}", + summary.path, + remove_error + ); + continue; + } + + 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(workspace_error) = + workspace_service.remove_workspace(&workspace.id).await + { + log::warn!( + "Automatically removed worktree {}, but its workspace registration could not be removed: {}", + summary.path, + workspace_error + ); + } + } + } + + registry + .worktrees + .retain(|record| record.worktree_id != candidate_id); + registry + .receipts + .retain(|_, receipt| receipt.worktree_id() != candidate_id); + removed_count += 1; + changed = true; + } + + if changed { + Self::save_registry(context, registry).await?; + } + Ok(removed_count) + } +} + +async fn known_project_workspace_paths() -> Vec { + let Some(workspace_service) = get_global_workspace_service() else { + return Vec::new(); + }; + let workspaces = workspace_service.list_workspaces().await; + let mut projects = HashMap::::new(); + + for workspace in workspaces { + if workspace.workspace_kind != WorkspaceKind::Normal || !workspace.root_path.is_dir() { + continue; + } + let Ok(worktrees) = GitService::list_worktrees(&workspace.root_path).await else { + continue; + }; + let Some(main_worktree) = worktrees.into_iter().find(|worktree| worktree.is_main) else { + continue; + }; + let main_path = PathBuf::from(main_worktree.path); + projects.insert(normalized_lookup_path(&main_path), main_path); + } + + let mut paths = projects.into_values().collect::>(); + paths.sort_by(|left, right| path_string(left).cmp(&path_string(right))); + paths +} + +fn automatic_delete_candidate_ids( + registry: &WorktreeRegistry, + limit: usize, + protected_worktree_id: &str, + now_ms: u64, +) -> Vec { + let mut managed = registry + .worktrees + .iter() + .filter(|record| record.lifecycle == WorktreeLifecycle::Managed) + .collect::>(); + managed.sort_by(|left, right| { + right + .created_at_ms + .cmp(&left.created_at_ms) + .then_with(|| right.worktree_id.cmp(&left.worktree_id)) + }); + + managed + .into_iter() + .skip(limit.max(1)) + .filter(|record| record.worktree_id != protected_worktree_id) + .filter(|record| now_ms.saturating_sub(record.created_at_ms) >= AUTO_DELETE_MIN_AGE_MS) + .map(|record| record.worktree_id.clone()) + .collect() } async fn notify_changed(project_workspace_path: &Path) { @@ -1542,9 +1747,10 @@ fn map_git_error(git_error: GitError) -> WorktreeError { #[cfg(test)] mod tests { use super::{ - managed_target_path, managed_worktree_directory_name, repository_id, resolve_managed_root, - sanitize_worktree_project_label, validate_removal, RegisteredWorktree, RepositoryContext, - WorktreeOperationReceipt, WorktreeRegistry, WorktreeService, + automatic_delete_candidate_ids, managed_target_path, managed_worktree_directory_name, + repository_id, resolve_managed_root, sanitize_worktree_project_label, validate_removal, + RegisteredWorktree, RepositoryContext, WorktreeOperationReceipt, WorktreeRegistry, + WorktreeService, AUTO_DELETE_MIN_AGE_MS, }; use crate::infrastructure::PathManager; use bitfun_core_types::{ @@ -1755,6 +1961,79 @@ mod tests { assert!(validate_removal(&summary, true).is_ok()); } + #[test] + fn automatic_cleanup_only_selects_managed_worktrees_older_than_the_limit() { + let project = Path::new("/repo"); + let mut registry = WorktreeRegistry::new(project); + for (worktree_id, lifecycle, created_at_ms) in [ + ("oldest", WorktreeLifecycle::Managed, 10), + ("older", WorktreeLifecycle::Managed, 20), + ("newer", WorktreeLifecycle::Managed, 30), + ("newest", WorktreeLifecycle::Managed, 40), + ("permanent", WorktreeLifecycle::Permanent, 1), + ("external", WorktreeLifecycle::External, 2), + ] { + registry.worktrees.push(RegisteredWorktree { + worktree_id: worktree_id.to_string(), + path: format!("/worktrees/{worktree_id}"), + base_ref: Some("main".to_string()), + base_commit: "0123456789abcdef".to_string(), + branch: None, + lifecycle, + created_at_ms, + }); + } + + assert_eq!( + automatic_delete_candidate_ids(®istry, 2, "newest", AUTO_DELETE_MIN_AGE_MS + 100,), + vec!["older".to_string(), "oldest".to_string()] + ); + } + + #[test] + fn automatic_cleanup_never_selects_the_newly_created_worktree() { + let project = Path::new("/repo"); + let mut registry = WorktreeRegistry::new(project); + for worktree_id in ["new", "old"] { + registry.worktrees.push(RegisteredWorktree { + worktree_id: worktree_id.to_string(), + path: format!("/worktrees/{worktree_id}"), + base_ref: Some("main".to_string()), + base_commit: "0123456789abcdef".to_string(), + branch: None, + lifecycle: WorktreeLifecycle::Managed, + created_at_ms: 10, + }); + } + + assert_eq!( + automatic_delete_candidate_ids(®istry, 1, "new", AUTO_DELETE_MIN_AGE_MS + 100,), + Vec::::new() + ); + } + + #[test] + fn automatic_cleanup_gives_new_worktrees_a_binding_grace_period() { + let project = Path::new("/repo"); + let mut registry = WorktreeRegistry::new(project); + for (worktree_id, created_at_ms) in [("newest", 100), ("recent", 90)] { + registry.worktrees.push(RegisteredWorktree { + worktree_id: worktree_id.to_string(), + path: format!("/worktrees/{worktree_id}"), + base_ref: Some("main".to_string()), + base_commit: "0123456789abcdef".to_string(), + branch: None, + lifecycle: WorktreeLifecycle::Managed, + created_at_ms, + }); + } + + assert!( + automatic_delete_candidate_ids(®istry, 1, "newest", AUTO_DELETE_MIN_AGE_MS,) + .is_empty() + ); + } + #[tokio::test] async fn registry_round_trip_restores_binding_and_idempotency_receipt() { let root = tempfile::tempdir().expect("temp root"); diff --git a/src/crates/contracts/core-types/src/worktree.rs b/src/crates/contracts/core-types/src/worktree.rs index 882d1e3127..457d2cea3c 100644 --- a/src/crates/contracts/core-types/src/worktree.rs +++ b/src/crates/contracts/core-types/src/worktree.rs @@ -76,11 +76,13 @@ impl SessionExecutionTarget { /// User-level defaults for worktrees created by session isolation. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(default, rename_all = "camelCase")] pub struct WorktreeSettings { pub root_path: String, pub branch_prefix: String, pub copy_local_changes: bool, + pub auto_delete_enabled: bool, + pub auto_delete_limit: usize, } impl Default for WorktreeSettings { @@ -89,6 +91,8 @@ impl Default for WorktreeSettings { root_path: "~/.bitfun/worktrees".to_string(), branch_prefix: "bitfun/".to_string(), copy_local_changes: false, + auto_delete_enabled: true, + auto_delete_limit: 15, } } } @@ -208,11 +212,29 @@ mod tests { } #[test] - fn worktree_defaults_are_safe_and_opt_in() { + fn worktree_defaults_include_managed_cleanup_policy() { let defaults = WorktreeSettings::default(); assert_eq!(defaults.root_path, "~/.bitfun/worktrees"); assert_eq!(defaults.branch_prefix, "bitfun/"); assert!(!defaults.copy_local_changes); + assert!(defaults.auto_delete_enabled); + assert_eq!(defaults.auto_delete_limit, 15); + } + + #[test] + fn legacy_worktree_settings_receive_auto_delete_defaults() { + let settings: WorktreeSettings = serde_json::from_value(serde_json::json!({ + "rootPath": "/custom/worktrees", + "branchPrefix": "custom/", + "copyLocalChanges": true + })) + .expect("legacy settings should deserialize"); + + assert_eq!(settings.root_path, "/custom/worktrees"); + assert_eq!(settings.branch_prefix, "custom/"); + assert!(settings.copy_local_changes); + assert!(settings.auto_delete_enabled); + assert_eq!(settings.auto_delete_limit, 15); } #[test] diff --git a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts index 9d29bfd3a2..95058a74cc 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts @@ -57,6 +57,10 @@ export const SETTINGS_TAB_SEARCH_CONTENT: Record { }); }); + it('lists the local workspace worktree catalog through a structured request', async () => { + invokeMock.mockResolvedValue([]); + + await api.listProjects(); + + expect(invokeMock).toHaveBeenCalledWith('worktree_list_projects', { + request: {}, + }); + }); + it('preserves stable structured error codes', async () => { const transportError = Object.assign(new Error('command failed'), { data: { diff --git a/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.ts b/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.ts index 43372f0779..4cd8f44766 100644 --- a/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/WorktreeAPI.ts @@ -11,6 +11,8 @@ export interface WorktreeSettings { rootPath: string; branchPrefix: string; copyLocalChanges: boolean; + autoDeleteEnabled: boolean; + autoDeleteLimit: number; } export interface SessionExecutionTarget { @@ -47,6 +49,11 @@ export interface WorktreeSummary { sessions: WorktreeSessionSummary[]; } +export interface WorktreeProjectSummary { + projectWorkspacePath: string; + worktrees: WorktreeSummary[]; +} + export type WorktreeErrorCode = | 'remote_unsupported' | 'not_git_repository' @@ -204,6 +211,10 @@ export class WorktreeAPI { return invokeWorktree('worktree_list', { projectWorkspacePath }); } + listProjects(): Promise { + return invokeWorktree('worktree_list_projects', {}); + } + create(request: WorktreeCreateRequest): Promise { return invokeWorktree('worktree_create', request); } diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss index bfb5191573..2424cc6aa4 100644 --- a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss @@ -1,12 +1,238 @@ .bitfun-worktrees-config { - .config-page-row__control { - min-width: 220px; - } - &__actions { display: flex; justify-content: flex-end; - gap: 8px; - padding-top: 4px; + gap: var(--size-gap-2); + padding-top: var(--size-gap-1); + } + + &__management-section { + > .bitfun-config-page-section__body { + border: 0; + border-radius: 0; + background: transparent; + } + } + + &__projects { + display: flex; + flex-direction: column; + gap: var(--size-gap-5); + } + + &__project { + min-width: 0; + overflow: hidden; + border: 1px solid var(--border-base); + border-radius: var(--size-radius-md); + background: var(--element-bg-subtle); + } + + &__project-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--size-gap-3); + padding: var(--size-gap-3) var(--size-gap-4); + border-bottom: 1px solid var(--border-subtle); + background: var(--element-bg-base); + + h4 { + min-width: 0; + margin: 0; + overflow: hidden; + color: var(--color-text-primary); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-base); + text-overflow: ellipsis; + white-space: nowrap; + } + + span { + flex-shrink: 0; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + } + } + + &__worktree { + padding: var(--size-gap-4); + + & + & { + border-top: 1px solid var(--border-subtle); + } + } + + &__worktree-main { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: var(--size-gap-4); + } + + &__worktree-copy { + min-width: 0; + } + + &__worktree-title { + margin: 0; + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-base); + } + + &__path { + display: block; + max-width: 100%; + margin-top: var(--size-gap-1); + overflow: hidden; + color: var(--color-text-secondary); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + line-height: var(--line-height-relaxed); + text-overflow: ellipsis; + white-space: nowrap; + } + + &__metadata { + display: flex; + flex-wrap: wrap; + gap: var(--size-gap-1) var(--size-gap-3); + margin-top: var(--size-gap-2); + color: var(--color-text-muted); + font-size: var(--font-size-xs); + line-height: var(--line-height-base); + } + + &__delete-control { + flex-shrink: 0; + } + + &__sessions { + margin-top: var(--size-gap-4); + } + + &__sessions-label { + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-base); + } + + &__session-list { + display: grid; + gap: var(--size-gap-1); + margin: var(--size-gap-2) 0 0; + padding: 0; + list-style: none; + + li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--size-gap-3); + align-items: baseline; + padding-left: var(--size-gap-2); + color: var(--color-text-primary); + font-size: var(--font-size-xs); + line-height: var(--line-height-relaxed); + } + } + + &__session-status { + color: var(--color-text-muted); + white-space: nowrap; + } + + &__sessions-empty { + margin-top: var(--size-gap-1); + color: var(--color-text-muted); + font-size: var(--font-size-xs); + line-height: var(--line-height-relaxed); + } + + &__protection-note { + margin-top: var(--size-gap-2); + color: var(--color-text-muted); + font-size: var(--font-size-xs); + line-height: var(--line-height-relaxed); + + &--warning { + color: var(--color-warning); + } + } + + &__empty { + display: flex; + align-items: flex-start; + gap: var(--size-gap-3); + padding: var(--size-gap-5); + border: 1px dashed var(--border-base); + border-radius: var(--size-radius-md); + color: var(--color-text-muted); + + svg { + flex-shrink: 0; + margin-top: 1px; + } + + h4 { + margin: 0; + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-base); + } + + p { + margin: var(--size-gap-1) 0 0; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + line-height: var(--line-height-relaxed); + } + } +} + +@container config-panel (max-width: 520px) { + .bitfun-worktrees-config { + &__actions { + justify-content: stretch; + + .btn { + flex: 1; + } + } + + &__project-header { + align-items: flex-start; + padding: var(--size-gap-3); + + h4 { + white-space: normal; + overflow-wrap: anywhere; + } + } + + &__worktree { + padding: var(--size-gap-3); + } + + &__worktree-main { + grid-template-columns: minmax(0, 1fr); + gap: var(--size-gap-3); + } + + &__delete-control { + .btn { + width: 100%; + } + } + + &__session-list li { + grid-template-columns: minmax(0, 1fr); + gap: 0; + } } } diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx new file mode 100644 index 0000000000..4869a3fa12 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx @@ -0,0 +1,331 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import WorktreesConfig from './WorktreesConfig'; + +const getConfigMock = vi.hoisted(() => vi.fn()); +const setConfigMock = vi.hoisted(() => vi.fn()); +const listProjectsMock = vi.hoisted(() => vi.fn()); +const removeMock = vi.hoisted(() => vi.fn()); +const onChangedMock = vi.hoisted(() => vi.fn(() => vi.fn())); +const translateMock = vi.hoisted(() => vi.fn( + (key: string, params?: Record) => { + if (key === 'labels.detached') return `detached ${params?.commit}`; + if (key === 'management.deleted') return `deleted ${params?.path}`; + return key; + }, +)); + +vi.mock('@/infrastructure/api', () => ({ + configAPI: { + getConfig: getConfigMock, + setConfig: setConfigMock, + }, + worktreeAPI: { + listProjects: listProjectsMock, + remove: removeMock, + onChanged: onChangedMock, + }, +})); + +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ + t: translateMock, + }), +})); + +vi.mock('@/component-library', () => ({ + Button: ({ + children, + disabled, + onClick, + }: { + children: React.ReactNode; + disabled?: boolean; + onClick?: () => void; + }) => ( + + ), + ConfigPageLoading: ({ text }: { text: string }) =>
{text}
, + ConfigPageMessage: ({ + message, + }: { + message: { text: string } | null; + }) => message ?
{message.text}
: null, + ConfigPageRefreshButton: ({ + onClick, + }: { + onClick: () => void; + }) => , + ConfirmDialog: ({ + confirmText, + isOpen, + message, + onConfirm, + title, + }: { + confirmText: string; + isOpen: boolean; + message: React.ReactNode; + onConfirm: () => void; + title: string; + }) => isOpen ? ( +
+

{title}

+
{message}
+ +
+ ) : null, + Input: (props: React.InputHTMLAttributes) => , + NumberInput: ({ + disabled, + label, + onChange, + value, + }: { + disabled?: boolean; + label?: string; + onChange: (value: number) => void; + value: number; + }) => ( + onChange(Number(event.currentTarget.value))} + /> + ), + Switch: ({ + checked, + disabled, + onChange, + }: { + checked: boolean; + disabled?: boolean; + onChange: React.ChangeEventHandler; + }) => ( + + ), +})); + +vi.mock('./common', () => ({ + ConfigPageContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + ConfigPageHeader: ({ title, subtitle }: { title: string; subtitle: string }) => ( +
+

{title}

+

{subtitle}

+
+ ), + ConfigPageLayout: ({ children }: { children: React.ReactNode }) =>
{children}
, + ConfigPageRow: ({ + children, + description, + label, + }: { + children: React.ReactNode; + description?: React.ReactNode; + label: React.ReactNode; + }) => ( + + ), + ConfigPageSection: ({ + children, + description, + extra, + title, + }: { + children: React.ReactNode; + description?: React.ReactNode; + extra?: React.ReactNode; + title: string; + }) => ( +
+

{title}

+

{description}

+ {extra} + {children} +
+ ), +})); + +function worktree(overrides: Record = {}) { + return { + worktreeId: 'wt-1', + projectWorkspacePath: '/repo', + path: '/managed/BitFun-wt-1', + head: '0123456789abcdef', + lifecycle: 'managed', + isMain: false, + dirty: false, + locked: false, + missing: false, + hasUnpublishedCommits: false, + associatedSessionCount: 1, + runningSessionCount: 0, + sessions: [{ + sessionId: 'session-1', + sessionName: 'Ship worktree management', + status: 'archived', + archived: true, + }], + ...overrides, + }; +} + +async function flushPromises() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('WorktreesConfig', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + + getConfigMock.mockResolvedValue({ + rootPath: '/custom/worktrees', + branchPrefix: 'custom/', + copyLocalChanges: false, + }); + setConfigMock.mockResolvedValue(undefined); + listProjectsMock.mockResolvedValue([{ + projectWorkspacePath: '/repo', + worktrees: [worktree()], + }]); + removeMock.mockResolvedValue({ worktreeId: 'wt-1', removed: true }); + onChangedMock.mockReturnValue(vi.fn()); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('adds safe auto-delete defaults and renders workspace worktrees with sessions', async () => { + await act(async () => { + root.render(); + }); + await flushPromises(); + + const checkboxes = container.querySelectorAll('input[type="checkbox"]'); + const limit = container.querySelector('input[type="number"]'); + + expect(checkboxes).toHaveLength(2); + expect(checkboxes[1].checked).toBe(true); + expect(limit?.value).toBe('15'); + expect(container.textContent).toContain('/repo'); + expect(container.textContent).toContain('/managed/BitFun-wt-1'); + expect(container.textContent).toContain('Ship worktree management'); + }); + + it('saves the auto-delete policy with the existing worktree settings', async () => { + await act(async () => { + root.render(); + }); + await flushPromises(); + + const limit = container.querySelector('input[type="number"]'); + expect(limit).not.toBeNull(); + await act(async () => { + if (limit) { + const setValue = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set; + setValue?.call(limit, '24'); + limit.dispatchEvent(new Event('input', { bubbles: true })); + } + }); + + const saveButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('settings.save')); + await act(async () => { + saveButton?.click(); + await Promise.resolve(); + }); + + expect(setConfigMock).toHaveBeenCalledWith('app.worktrees', { + rootPath: '/custom/worktrees', + branchPrefix: 'custom/', + copyLocalChanges: false, + autoDeleteEnabled: true, + autoDeleteLimit: 24, + }); + }); + + it('requires confirmation and uses force only when local work would be discarded', async () => { + listProjectsMock.mockResolvedValueOnce([{ + projectWorkspacePath: '/repo', + worktrees: [worktree({ dirty: true })], + }]); + + await act(async () => { + root.render(); + }); + await flushPromises(); + + const deleteButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('management.delete.action')); + act(() => deleteButton?.click()); + + expect(container.querySelector('[role="dialog"]')?.textContent) + .toContain('management.delete.forceTitle'); + + const confirmButton = container.querySelector('[data-testid="confirm-delete"]'); + await act(async () => { + confirmButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(removeMock).toHaveBeenCalledWith( + '/repo', + 'wt-1', + expect.any(String), + true, + ); + }); + + it('disables deletion while a worktree still has unarchived sessions', async () => { + listProjectsMock.mockResolvedValueOnce([{ + projectWorkspacePath: '/repo', + worktrees: [worktree({ runningSessionCount: 1 })], + }]); + + await act(async () => { + root.render(); + }); + await flushPromises(); + + const deleteButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('management.delete.action')); + + expect(deleteButton?.disabled).toBe(true); + expect(container.textContent).toContain('management.protection.activeSessions'); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx index 8bdf785b7b..8faae38743 100644 --- a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx @@ -1,14 +1,28 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { GitBranch, RotateCcw, Save } from 'lucide-react'; +import { + FolderGit2, + GitBranch, + RotateCcw, + Save, + Trash2, +} from 'lucide-react'; import { Button, ConfigPageLoading, ConfigPageMessage, + ConfigPageRefreshButton, + ConfirmDialog, Input, + NumberInput, Switch, } from '@/component-library'; -import { configAPI } from '@/infrastructure/api'; -import type { WorktreeSettings } from '@/infrastructure/api/service-api/WorktreeAPI'; +import { configAPI, worktreeAPI } from '@/infrastructure/api'; +import type { + WorktreeCommandError, + WorktreeProjectSummary, + WorktreeSettings, + WorktreeSummary, +} from '@/infrastructure/api/service-api/WorktreeAPI'; import { useI18n } from '@/infrastructure/i18n'; import { ConfigPageContent, @@ -19,78 +33,213 @@ import { } from './common'; import './WorktreesConfig.scss'; +const AUTO_DELETE_LIMIT_MIN = 1; +const AUTO_DELETE_LIMIT_MAX = 100; + const DEFAULT_SETTINGS: WorktreeSettings = { rootPath: '~/.bitfun/worktrees', branchPrefix: 'bitfun/', copyLocalChanges: false, + autoDeleteEnabled: true, + autoDeleteLimit: 15, +}; + +interface DeleteTarget { + projectWorkspacePath: string; + worktree: WorktreeSummary; +} + +type PageMessage = { + type: 'success' | 'error' | 'info' | 'warning'; + text: string; }; +function normalizeSettings(configured: unknown): WorktreeSettings { + const value = configured && typeof configured === 'object' + ? configured as Partial + : {}; + const configuredLimit = typeof value.autoDeleteLimit === 'number' + && Number.isFinite(value.autoDeleteLimit) + ? Math.round(value.autoDeleteLimit) + : DEFAULT_SETTINGS.autoDeleteLimit; + + return { + rootPath: typeof value.rootPath === 'string' + ? value.rootPath + : DEFAULT_SETTINGS.rootPath, + branchPrefix: typeof value.branchPrefix === 'string' + ? value.branchPrefix + : DEFAULT_SETTINGS.branchPrefix, + copyLocalChanges: typeof value.copyLocalChanges === 'boolean' + ? value.copyLocalChanges + : DEFAULT_SETTINGS.copyLocalChanges, + autoDeleteEnabled: typeof value.autoDeleteEnabled === 'boolean' + ? value.autoDeleteEnabled + : DEFAULT_SETTINGS.autoDeleteEnabled, + autoDeleteLimit: Math.min( + AUTO_DELETE_LIMIT_MAX, + Math.max(AUTO_DELETE_LIMIT_MIN, configuredLimit), + ), + }; +} + +function createDeleteRequestId(): string { + return globalThis.crypto?.randomUUID?.() + ?? `worktree-settings-delete-${Date.now()}-${Math.random()}`; +} + +type DeletionBlockReason = 'activeSessions' | 'locked' | 'missing'; + +function deletionBlockReason(worktree: WorktreeSummary): DeletionBlockReason | null { + if (worktree.runningSessionCount > 0) return 'activeSessions'; + if (worktree.locked) return 'locked'; + if (worktree.missing) return 'missing'; + return null; +} + const WorktreesConfig: React.FC = () => { const { t } = useI18n('worktrees'); const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [loading, setLoading] = useState(true); + const [settingsLoading, setSettingsLoading] = 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); + const [settingsMessage, setSettingsMessage] = useState(null); + const [projects, setProjects] = useState([]); + const [projectsLoading, setProjectsLoading] = useState(true); + const [projectsMessage, setProjectsMessage] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deletingWorktreeId, setDeletingWorktreeId] = useState(null); + + const loadSettings = useCallback(async () => { + setSettingsLoading(true); + setSettingsMessage(null); try { const configured = await configAPI.getConfig('app.worktrees', { skipRetryOnNotFound: true, }); - setSettings({ - ...DEFAULT_SETTINGS, - ...(configured && typeof configured === 'object' ? configured : {}), - }); + setSettings(normalizeSettings(configured)); } catch { - setMessage({ type: 'error', text: t('settings.loadFailed') }); + setSettingsMessage({ type: 'error', text: t('settings.loadFailed') }); } finally { - setLoading(false); + setSettingsLoading(false); + } + }, [t]); + + const loadProjects = useCallback(async () => { + setProjectsLoading(true); + setProjectsMessage(null); + try { + setProjects(await worktreeAPI.listProjects()); + } catch { + setProjectsMessage({ type: 'error', text: t('management.loadFailed') }); + } finally { + setProjectsLoading(false); } }, [t]); useEffect(() => { - void load(); - }, [load]); + void loadSettings(); + void loadProjects(); + return worktreeAPI.onChanged(() => { + void loadProjects(); + }); + }, [loadProjects, loadSettings]); const save = async () => { if (!settings.rootPath.trim() || !settings.branchPrefix.trim()) { - setMessage({ type: 'error', text: t('settings.required') }); + setSettingsMessage({ type: 'error', text: t('settings.required') }); return; } + if ( + settings.autoDeleteLimit < AUTO_DELETE_LIMIT_MIN + || settings.autoDeleteLimit > AUTO_DELETE_LIMIT_MAX + ) { + setSettingsMessage({ + type: 'error', + text: t('settings.autoDeleteLimit.invalid', { + min: AUTO_DELETE_LIMIT_MIN, + max: AUTO_DELETE_LIMIT_MAX, + }), + }); + return; + } + setSaving(true); - setMessage(null); + setSettingsMessage(null); try { - await configAPI.setConfig('app.worktrees', { + const normalized = { ...settings, rootPath: settings.rootPath.trim(), branchPrefix: settings.branchPrefix.trim(), - }); - setMessage({ type: 'success', text: t('settings.saved') }); + autoDeleteLimit: Math.round(settings.autoDeleteLimit), + }; + await configAPI.setConfig('app.worktrees', normalized); + setSettings(normalized); + setSettingsMessage({ type: 'success', text: t('settings.saved') }); } catch { - setMessage({ type: 'error', text: t('settings.saveFailed') }); + setSettingsMessage({ type: 'error', text: t('settings.saveFailed') }); } finally { setSaving(false); } }; - if (loading) { - return ; - } + const confirmDelete = async () => { + const target = deleteTarget; + if (!target) return; - return ( - - } - title={t('settings.title')} - subtitle={t('settings.description')} - /> - - + setDeleteTarget(null); + setDeletingWorktreeId(target.worktree.worktreeId); + setProjectsMessage(null); + try { + const discardLocalWork = + target.worktree.dirty || target.worktree.hasUnpublishedCommits; + await worktreeAPI.remove( + target.projectWorkspacePath, + target.worktree.worktreeId, + createDeleteRequestId(), + discardLocalWork, + ); + await loadProjects(); + setProjectsMessage({ + type: 'success', + text: t('management.deleted', { path: target.worktree.path }), + }); + } catch (error) { + const code = (error as Partial | null)?.code; + const text = (() => { + switch (code) { + case 'worktree_busy': + return t('management.errors.activeSessions'); + case 'worktree_locked': + return t('management.errors.locked'); + case 'dirty_worktree': + return t('management.errors.dirty'); + case 'unpublished_commits': + return t('management.errors.unpublishedCommits'); + case 'worktree_not_found': + return t('management.errors.notFound'); + case 'remote_unsupported': + return t('management.errors.remoteUnsupported'); + default: + return t('management.errors.deleteFailed'); + } + })(); + setProjectsMessage({ + type: 'error', + text, + }); + } finally { + setDeletingWorktreeId(null); + } + }; + + const renderSettings = () => { + if (settingsLoading) { + return ; + } + + return ( + <> + { disabled={saving} /> + + setSettings(current => ({ + ...current, + autoDeleteEnabled: event.target.checked, + }))} + disabled={saving} + /> + + + setSettings(current => ({ + ...current, + autoDeleteLimit: value, + }))} + min={AUTO_DELETE_LIMIT_MIN} + max={AUTO_DELETE_LIMIT_MAX} + showButtons={false} + disableWheel + disabled={saving || !settings.autoDeleteEnabled} + /> +
+ + ); + }; + + const renderWorktree = ( + project: WorktreeProjectSummary, + worktree: WorktreeSummary, + ) => { + const blockCode = deletionBlockReason(worktree); + const blockReason = (() => { + switch (blockCode) { + case 'activeSessions': + return t('management.protection.activeSessions'); + case 'locked': + return t('management.protection.locked'); + case 'missing': + return t('management.protection.missing'); + default: + return null; + } + })(); + const branchLabel = worktree.branch + ?? t('labels.detached', { commit: worktree.head.slice(0, 7) }); + const forceDelete = worktree.dirty || worktree.hasUnpublishedCommits; + const lifecycleLabel = (() => { + switch (worktree.lifecycle) { + case 'permanent': + return t('management.lifecycle.permanent'); + case 'external': + return t('management.lifecycle.external'); + default: + return t('management.lifecycle.managed'); + } + })(); + + return ( +
+
+
+
{branchLabel}
+ + {worktree.path} + +
+ {lifecycleLabel} + {worktree.dirty && {t('management.state.dirty')}} + {worktree.hasUnpublishedCommits && ( + {t('management.state.unpublishedCommits')} + )} + {worktree.locked && {t('management.state.locked')}} + {worktree.missing && {t('management.state.missing')}} + {worktree.runningSessionCount > 0 && ( + + {t('management.state.activeSessions', { + count: worktree.runningSessionCount, + })} + + )} +
+
+
+ +
+
+ +
+
+ {t('management.sessions.title')} +
+ {worktree.sessions.length > 0 ? ( +
    + {worktree.sessions.map(session => ( +
  • + {session.sessionName} + + {session.status === 'archived' + ? t('management.sessions.status.archived') + : session.status === 'completed' + ? t('shared:statuses.done') + : t('management.sessions.status.active')} + +
  • + ))} +
+ ) : ( +
+ {t('management.sessions.empty')} +
+ )} + {blockReason && ( +
+ {blockReason} +
+ )} + {forceDelete && !blockReason && ( +
+ {t('management.delete.forceHint')} +
+ )} +
+
+ ); + }; + + const renderProjects = () => { + if (projectsLoading) { + return ; + } + if (projects.length === 0 && !projectsMessage) { + return ( +
+ +
+

{t('management.empty.title')}

+

{t('management.empty.description')}

+
+
+ ); + } + if (projects.length === 0) { + return null; + } + + return ( +
+ {projects.map(project => ( +
+
+

+ {project.projectWorkspacePath} +

+ + {t('management.worktreeCount', { count: project.worktrees.length })} + +
+
+ {project.worktrees.map(worktree => renderWorktree(project, worktree))} +
+
+ ))} +
+ ); + }; + + const deletingWithLocalWork = Boolean( + deleteTarget?.worktree.dirty || deleteTarget?.worktree.hasUnpublishedCommits, + ); + + return ( + + } + title={t('settings.title')} + subtitle={t('settings.description')} + /> + + {renderSettings()} + void loadProjects()} + loading={projectsLoading} + disabled={deletingWorktreeId !== null} + /> + )} + > + + {projectsMessage?.type === 'error' && projects.length === 0 && ( + + )} + {renderProjects()} + + + setDeleteTarget(null)} + onConfirm={() => void confirmDelete()} + title={deletingWithLocalWork + ? t('management.delete.forceTitle') + : t('management.delete.title')} + message={deletingWithLocalWork + ? t('management.delete.forceMessage') + : t('management.delete.message', { + count: deleteTarget?.worktree.associatedSessionCount ?? 0, + })} + preview={deleteTarget?.worktree.path} + type={deletingWithLocalWork ? 'error' : 'warning'} + confirmDanger + confirmText={deletingWithLocalWork + ? t('management.delete.forceAction') + : t('management.delete.action')} + cancelText={t('management.delete.cancel')} + /> ); }; diff --git a/src/web-ui/src/locales/en-US/worktrees.json b/src/web-ui/src/locales/en-US/worktrees.json index d984605062..6b3d027107 100644 --- a/src/web-ui/src/locales/en-US/worktrees.json +++ b/src/web-ui/src/locales/en-US/worktrees.json @@ -36,6 +36,74 @@ "copyChanges": { "label": "Copy local changes by default", "description": "Off by default. Copies uncommitted project changes into the new worktree, preserving staging." + }, + "autoDelete": { + "label": "Automatically delete old worktrees", + "description": "After creating a worktree, BitFun removes the oldest managed worktrees beyond the workspace limit when they are safe to delete." + }, + "autoDeleteLimit": { + "label": "Auto-delete limit", + "description": "Managed worktrees to keep per workspace. Worktrees with unarchived sessions, local changes, unpublished commits, or Git locks are always kept.", + "invalid": "Enter a number from {{min}} to {{max}}." + } + }, + "management": { + "title": "Workspace worktrees", + "description": "Local workspaces and their linked worktrees. Refresh to rescan Git and session associations.", + "loading": "Scanning local worktrees…", + "loadFailed": "Could not load the local worktree list.", + "refresh": "Refresh worktrees", + "retry": "Try again", + "worktreeCount": "Worktrees: {{count}}", + "deleted": "Deleted {{path}}.", + "empty": { + "title": "No linked worktrees", + "description": "Worktrees created for isolated sessions will appear here." + }, + "lifecycle": { + "managed": "Managed", + "permanent": "Kept permanently", + "external": "External" + }, + "state": { + "dirty": "Local changes", + "unpublishedCommits": "Unpublished commits", + "locked": "Git locked", + "missing": "Directory missing", + "activeSessions": "Unarchived sessions: {{count}}" + }, + "sessions": { + "title": "Conversations", + "empty": "No associated conversations.", + "status": { + "active": "Active", + "archived": "Archived" + } + }, + "protection": { + "activeSessions": "Archive or move all associated sessions before deleting this worktree.", + "locked": "Unlock this worktree in Git before deleting it.", + "missing": "The worktree directory is missing. Clean up the stale Git record manually." + }, + "delete": { + "action": "Delete", + "actionLabel": "Delete worktree {{path}}", + "title": "Delete this worktree?", + "message": "Associated conversations remain in history ({{count}}). The checkout will be removed.", + "forceTitle": "Delete and discard local work?", + "forceMessage": "This worktree contains local changes or detached commits. Deleting it will permanently discard that local work.", + "forceHint": "Deleting this worktree requires confirmation to discard its local work.", + "forceAction": "Delete and discard", + "cancel": "Cancel" + }, + "errors": { + "activeSessions": "This worktree has unarchived sessions and cannot be deleted.", + "locked": "This worktree is locked by Git and cannot be deleted.", + "dirty": "This worktree still contains local changes.", + "unpublishedCommits": "This worktree still contains unpublished detached commits.", + "notFound": "This worktree no longer exists. Refresh the list and try again.", + "remoteUnsupported": "Remote SSH worktrees are not supported yet.", + "deleteFailed": "Could not delete the worktree." } } } diff --git a/src/web-ui/src/locales/zh-CN/worktrees.json b/src/web-ui/src/locales/zh-CN/worktrees.json index e81633d5c5..807e5b8234 100644 --- a/src/web-ui/src/locales/zh-CN/worktrees.json +++ b/src/web-ui/src/locales/zh-CN/worktrees.json @@ -36,6 +36,74 @@ "copyChanges": { "label": "默认复制本地改动", "description": "默认关闭;开启后会把项目中未提交的改动复制到新 worktree,并保留暂存状态。" + }, + "autoDelete": { + "label": "自动删除旧 Worktree", + "description": "新建 worktree 后,如果工作区超过保留数量,BitFun 会删除最旧且可安全清理的托管 worktree。" + }, + "autoDeleteLimit": { + "label": "自动删除上限", + "description": "每个工作区保留的托管 worktree 数量。包含未归档会话、本地改动、未发布提交或 Git 锁的 worktree 始终会被保留。", + "invalid": "请输入 {{min}} 到 {{max}} 之间的数字。" + } + }, + "management": { + "title": "工作区 Worktrees", + "description": "查看本地工作区及其关联的 worktree。刷新后会重新扫描 Git 状态和会话关联。", + "loading": "正在扫描本地 Worktree…", + "loadFailed": "无法加载本地 Worktree 列表。", + "refresh": "刷新 Worktree", + "retry": "重试", + "worktreeCount": "Worktree:{{count}}", + "deleted": "已删除 {{path}}。", + "empty": { + "title": "暂无关联的 Worktree", + "description": "为隔离会话创建的 worktree 会显示在这里。" + }, + "lifecycle": { + "managed": "托管", + "permanent": "永久保留", + "external": "外部" + }, + "state": { + "dirty": "有本地改动", + "unpublishedCommits": "有未发布提交", + "locked": "Git 已锁定", + "missing": "目录缺失", + "activeSessions": "未归档会话:{{count}}" + }, + "sessions": { + "title": "会话", + "empty": "没有关联会话。", + "status": { + "active": "进行中", + "archived": "已归档" + } + }, + "protection": { + "activeSessions": "请先归档或移动所有关联会话,再删除此 worktree。", + "locked": "请先在 Git 中解锁此 worktree,再执行删除。", + "missing": "Worktree 目录已缺失,请手动清理过期的 Git 记录。" + }, + "delete": { + "action": "删除", + "actionLabel": "删除 Worktree {{path}}", + "title": "删除此 Worktree?", + "message": "关联会话会保留在历史记录中({{count}} 个),当前检出目录将被删除。", + "forceTitle": "删除并丢弃本地工作?", + "forceMessage": "此 worktree 包含本地改动或游离提交。删除后,这些本地工作将永久丢失。", + "forceHint": "删除此 worktree 需要再次确认,并会丢弃其中的本地工作。", + "forceAction": "删除并丢弃", + "cancel": "取消" + }, + "errors": { + "activeSessions": "此 worktree 包含未归档会话,无法删除。", + "locked": "此 worktree 已被 Git 锁定,无法删除。", + "dirty": "此 worktree 仍包含本地改动。", + "unpublishedCommits": "此 worktree 仍包含未发布的游离提交。", + "notFound": "此 worktree 已不存在,请刷新列表后重试。", + "remoteUnsupported": "暂不支持远程 SSH Worktree。", + "deleteFailed": "无法删除 Worktree。" } } } diff --git a/src/web-ui/src/locales/zh-TW/worktrees.json b/src/web-ui/src/locales/zh-TW/worktrees.json index 041fa66007..aed3c9119d 100644 --- a/src/web-ui/src/locales/zh-TW/worktrees.json +++ b/src/web-ui/src/locales/zh-TW/worktrees.json @@ -36,6 +36,74 @@ "copyChanges": { "label": "預設複製本機變更", "description": "預設關閉;開啟後會把專案中未提交的變更複製到新 worktree,並保留暫存狀態。" + }, + "autoDelete": { + "label": "自動刪除舊 Worktree", + "description": "新建 worktree 後,如果工作區超過保留數量,BitFun 會刪除最舊且可安全清理的託管 worktree。" + }, + "autoDeleteLimit": { + "label": "自動刪除上限", + "description": "每個工作區保留的託管 worktree 數量。包含未封存工作階段、本機變更、未發布提交或 Git 鎖定的 worktree 一律會保留。", + "invalid": "請輸入 {{min}} 到 {{max}} 之間的數字。" + } + }, + "management": { + "title": "工作區 Worktrees", + "description": "檢視本機工作區及其關聯的 worktree。重新整理後會再次掃描 Git 狀態和工作階段關聯。", + "loading": "正在掃描本機 Worktree…", + "loadFailed": "無法載入本機 Worktree 清單。", + "refresh": "重新整理 Worktree", + "retry": "重試", + "worktreeCount": "Worktree:{{count}}", + "deleted": "已刪除 {{path}}。", + "empty": { + "title": "目前沒有關聯的 Worktree", + "description": "為隔離工作階段建立的 worktree 會顯示在這裡。" + }, + "lifecycle": { + "managed": "託管", + "permanent": "永久保留", + "external": "外部" + }, + "state": { + "dirty": "有本機變更", + "unpublishedCommits": "有未發布提交", + "locked": "Git 已鎖定", + "missing": "目錄遺失", + "activeSessions": "未封存工作階段:{{count}}" + }, + "sessions": { + "title": "工作階段", + "empty": "沒有關聯的工作階段。", + "status": { + "active": "進行中", + "archived": "已封存" + } + }, + "protection": { + "activeSessions": "請先封存或移動所有關聯工作階段,再刪除此 worktree。", + "locked": "請先在 Git 中解鎖此 worktree,再執行刪除。", + "missing": "Worktree 目錄已遺失,請手動清理過期的 Git 記錄。" + }, + "delete": { + "action": "刪除", + "actionLabel": "刪除 Worktree {{path}}", + "title": "刪除此 Worktree?", + "message": "關聯工作階段會保留在歷史記錄中({{count}} 個),目前的簽出目錄將被刪除。", + "forceTitle": "刪除並捨棄本機工作?", + "forceMessage": "此 worktree 包含本機變更或游離提交。刪除後,這些本機工作將永久遺失。", + "forceHint": "刪除此 worktree 需要再次確認,並會捨棄其中的本機工作。", + "forceAction": "刪除並捨棄", + "cancel": "取消" + }, + "errors": { + "activeSessions": "此 worktree 包含未封存工作階段,無法刪除。", + "locked": "此 worktree 已被 Git 鎖定,無法刪除。", + "dirty": "此 worktree 仍包含本機變更。", + "unpublishedCommits": "此 worktree 仍包含未發布的游離提交。", + "notFound": "此 worktree 已不存在,請重新整理清單後重試。", + "remoteUnsupported": "目前不支援遠端 SSH Worktree。", + "deleteFailed": "無法刪除 Worktree。" } } } From ae8800e6d1a5d1281477b04ae55e3a7aefa863c2 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 28 Jul 2026 19:28:45 -0700 Subject: [PATCH 2/2] fix(worktree): harden agent session integration --- .../prompt_builder/prompt_builder_impl.rs | 66 +++++++++++ .../src/agentic/coordination/coordinator.rs | 111 +++++++++++++++++- .../assembly/core/src/service/worktree/mod.rs | 6 +- .../execution/agent-runtime/src/prompt.rs | 65 +++++++++- .../agent-runtime/tests/prompt_contracts.rs | 31 ++++- .../components/WorktreesConfig.test.tsx | 12 +- .../config/components/WorktreesConfig.tsx | 14 +-- src/web-ui/src/locales/en-US/worktrees.json | 8 +- src/web-ui/src/locales/zh-CN/worktrees.json | 8 +- src/web-ui/src/locales/zh-TW/worktrees.json | 8 +- 10 files changed, 298 insertions(+), 31 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs index 3b6029eb68..3b80b5ec6e 100644 --- a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs +++ b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs @@ -21,8 +21,10 @@ use bitfun_agent_runtime::prompt::{ render_workspace_context, PrependedPromptReminders, ProjectLayoutFacts, PromptRelatedPath, RemoteExecutionHints, RuntimeContextFacts, RuntimeContextNeeds, RuntimeShellFacts, ToolListingSections, UserContextPolicy, UserContextSection, WorkspaceContextFacts, + WorktreeContextFacts, }; use bitfun_agent_runtime::remote_file_delivery::user_workspace_relative_file_link; +use bitfun_core_types::SessionExecutionTargetKind; use log::{debug, info, warn}; use std::path::Path; @@ -44,6 +46,8 @@ pub struct PromptBuilderContext { pub model_name: Option, /// When set, file/shell tools target this remote environment; OS and path instructions follow it. pub remote_execution: Option, + /// Explicit worktree identity and owning-project facts shown to the agent. + pub worktree: Option, /// Pre-built tree text for `{PROJECT_LAYOUT}` when the workspace is not on the local disk. pub remote_project_layout: Option, /// When `Some(false)`, runtime context includes Computer use text-only guidance (no screenshot tool output). @@ -74,6 +78,7 @@ impl PromptBuilderContext { session_id, model_name, remote_execution: None, + worktree: None, remote_project_layout: None, supports_image_understanding: None, tool_listing_sections: ToolListingSections::default(), @@ -115,6 +120,11 @@ impl PromptBuilderContext { self } + pub fn with_worktree_context(mut self, worktree: WorktreeContextFacts) -> Self { + self.worktree = Some(worktree); + self + } + pub fn with_remote_file_delivery_channel(mut self, enabled: bool) -> Self { self.remote_file_delivery_channel = enabled; self @@ -165,6 +175,16 @@ pub async fn build_prompt_context_for_workspace( .with_related_paths(related_paths) .with_tool_listing_sections(tool_listing_sections) .with_runtime_context_needs(runtime_context_needs); + if let Some(execution_target) = workspace + .execution_target + .as_ref() + .filter(|target| target.kind != SessionExecutionTargetKind::Local) + { + base = base.with_worktree_context(WorktreeContextFacts { + project_workspace_path: workspace.project_root_path_string(), + execution_target: execution_target.clone(), + }); + } if let Some(supports_image_understanding) = supports_image_understanding { base = base.with_supports_image_understanding(supports_image_understanding); } @@ -282,6 +302,7 @@ impl PromptBuilder { }) .collect(), remote_execution: self.context.remote_execution.clone(), + worktree: self.context.worktree.clone(), }) } @@ -620,13 +641,19 @@ async fn memory_summary_enabled() -> bool { #[cfg(test)] mod tests { + use super::build_prompt_context_for_workspace; use super::PromptBuilder; use super::PromptBuilderContext; use super::RemoteExecutionHints; use super::RuntimeContextNeeds; use super::ToolListingSections; use crate::agentic::agents::UserContextPolicy; + use crate::agentic::WorkspaceBinding; use crate::service::workspace::RelatedPath; + use bitfun_core_types::{ + SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, + }; + use std::path::PathBuf; #[tokio::test] async fn builds_ordered_prepended_reminders_from_tool_listings_and_user_context() { @@ -1057,4 +1084,43 @@ mod tests { assert!(workspace_context.contains(" - monorepo/packages/payments")); assert!(!workspace_context.contains("payments —")); } + + #[tokio::test] + async fn workspace_context_identifies_the_managed_worktree_binding() { + let execution_target = SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("wt-1".to_string()), + root_path: "/managed/BitFun-wt-1".to_string(), + base_ref: Some("HEAD".to_string()), + base_commit: Some("0123456789abcdef".to_string()), + branch: None, + lifecycle: Some(WorktreeLifecycle::Managed), + }; + let workspace = WorkspaceBinding::new( + Some("workspace-1".to_string()), + PathBuf::from("/managed/BitFun-wt-1"), + ) + .with_project_root_path(PathBuf::from("/projects/BitFun")) + .with_execution_target(Some(execution_target)); + let context = build_prompt_context_for_workspace( + &workspace, + None, + "session-1", + Some("primary".to_string()), + None, + ToolListingSections::default(), + RuntimeContextNeeds::default(), + ) + .await + .expect("prompt context should build"); + + let workspace_context = PromptBuilder::new(context).get_workspace_context(); + + assert!(workspace_context.contains("Managed Git worktree created for this session")); + assert!(workspace_context.contains("Owning project root")); + assert!(workspace_context.contains("/projects/BitFun")); + assert!(workspace_context.contains("Worktree ID: wt-1")); + assert!(workspace_context.contains("Worktree checkout: detached HEAD")); + assert!(workspace_context.contains("Worktree base commit: 0123456789abcdef")); + } } diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 879d71a35b..30a0c11701 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -206,6 +206,29 @@ async fn normalize_model_selection(model_id: &str) -> BitFunResult { } } +fn inherit_matching_parent_workspace_binding( + parent_config: &SessionConfig, + child_config: &mut SessionConfig, +) { + let Some(parent_workspace_path) = parent_config.workspace_path.as_deref() else { + return; + }; + let Some(child_workspace_path) = child_config.workspace_path.as_deref() else { + return; + }; + if comparable_workspace_path(parent_workspace_path) + != comparable_workspace_path(child_workspace_path) + { + return; + } + + child_config.project_workspace_path = parent_config.project_workspace_path.clone(); + child_config.execution_target = parent_config.execution_target.clone(); + child_config.workspace_id = parent_config.workspace_id.clone(); + child_config.remote_connection_id = parent_config.remote_connection_id.clone(); + child_config.remote_ssh_host = parent_config.remote_ssh_host.clone(); +} + fn resolve_subagent_model_selection( explicit_model_id: Option<&str>, configured_selection: &SubagentModelSelection, @@ -7369,7 +7392,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "session-{}", request.subagent_parent_info.session_id )); - self.session_manager + let parent_session = self + .session_manager .get_session(&request.subagent_parent_info.session_id) .ok_or_else(|| { BitFunError::NotFound(format!( @@ -7522,6 +7546,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Some(resolved_model_id), ) .await; + inherit_matching_parent_workspace_binding( + &parent_session.config, + &mut session_config, + ); session_config.continuation_policy = request.continuation_policy; session_config.model_binding_policy = request.model_binding_policy; session_config.model_binding_fingerprint = approved_model_binding @@ -9632,6 +9660,9 @@ mod tests { use crate::service::session::{SessionMetadata, SessionStatus}; use crate::service::workspace::WorkspaceKind; use bitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY; + use bitfun_core_types::{ + SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, + }; use bitfun_runtime_ports::{ AgentSessionArchiveRequest, AgentSessionCreateRequest, AgentSessionManagementPort, AgentSessionRenameRequest, AgentSubmissionPort, AgentSubmissionRequest, @@ -11949,6 +11980,84 @@ mod tests { assert_eq!(model_id, "primary"); } + #[tokio::test] + async fn fresh_subagent_inherits_matching_parent_worktree_binding() { + let (coordinator, session_manager) = test_coordinator(); + let temp_root = tempfile::tempdir().expect("temp root should exist"); + let project_path = temp_root.path().join("BitFun"); + let worktree_path = temp_root.path().join("managed-worktree"); + std::fs::create_dir_all(&project_path).expect("project dir should exist"); + std::fs::create_dir_all(&worktree_path).expect("worktree dir should exist"); + let project_workspace_path = project_path.to_string_lossy().into_owned(); + let workspace_path = worktree_path.to_string_lossy().into_owned(); + let execution_target = SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("worktree-1".to_string()), + root_path: workspace_path.clone(), + base_ref: Some("HEAD".to_string()), + base_commit: Some("0123456789abcdef".to_string()), + branch: None, + lifecycle: Some(WorktreeLifecycle::Managed), + }; + let parent_session = session_manager + .create_session( + "Parent".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("primary".to_string()), + workspace_path: Some(workspace_path.clone()), + project_workspace_path: Some(project_workspace_path.clone()), + execution_target: Some(execution_target.clone()), + workspace_id: Some("workspace-1".to_string()), + ..Default::default() + }, + ) + .await + .expect("parent session should be created"); + + let resolved = coordinator + .resolve_hidden_subagent_execution_request(SubagentExecutionRequest { + task_description: "Inspect the managed worktree".to_string(), + context_mode: SubagentContextMode::Fresh, + target_session_id: None, + subagent_type: Some("Explore".to_string()), + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, + workspace_path: Some(workspace_path.clone()), + model_id: Some("primary".to_string()), + inherit_parent_model: false, + subagent_parent_info: SubagentParentInfo { + session_id: parent_session.session_id, + dialog_turn_id: "parent-turn".to_string(), + tool_call_id: "task-tool".to_string(), + }, + context: HashMap::new(), + permission_runtime_ceiling: PermissionRuntimeCeiling::default(), + delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, + }) + .await + .expect("fresh subagent request should resolve"); + + assert_eq!( + resolved.session_config.workspace_path.as_deref(), + Some(workspace_path.as_str()) + ); + assert_eq!( + resolved.session_config.project_workspace_path.as_deref(), + Some(project_workspace_path.as_str()) + ); + assert_eq!( + resolved.session_config.execution_target.as_ref(), + Some(&execution_target) + ); + assert_eq!( + resolved.session_config.workspace_id.as_deref(), + Some("workspace-1") + ); + } + #[tokio::test] async fn fresh_subagent_inherits_transient_parent_persistence_boundary() { let (coordinator, session_manager) = test_coordinator(); diff --git a/src/crates/assembly/core/src/service/worktree/mod.rs b/src/crates/assembly/core/src/service/worktree/mod.rs index 020e91251d..edd174f9de 100644 --- a/src/crates/assembly/core/src/service/worktree/mod.rs +++ b/src/crates/assembly/core/src/service/worktree/mod.rs @@ -1667,10 +1667,10 @@ fn validate_removal(summary: &WorktreeSummary, force: bool) -> Result<(), Worktr "The worktree is locked by Git", )); } - if summary.running_session_count > 0 { + if summary.associated_session_count > 0 { return Err(error( WorktreeErrorCode::WorktreeBusy, - "The worktree has active or unarchived sessions", + "The worktree has associated sessions; delete them before removing the worktree", )); } if !force && summary.dirty { @@ -1925,7 +1925,7 @@ mod tests { ); let mut summary = removable_summary(); - summary.running_session_count = 1; + summary.associated_session_count = 1; assert_eq!( validate_removal(&summary, true).unwrap_err().code, WorktreeErrorCode::WorktreeBusy diff --git a/src/crates/execution/agent-runtime/src/prompt.rs b/src/crates/execution/agent-runtime/src/prompt.rs index f2e5715f24..a576206bcc 100644 --- a/src/crates/execution/agent-runtime/src/prompt.rs +++ b/src/crates/execution/agent-runtime/src/prompt.rs @@ -1,5 +1,6 @@ //! Prompt-loop owner facts and reminder ordering. +use bitfun_core_types::{SessionExecutionTarget, SessionExecutionTargetKind}; use serde::{Deserialize, Serialize}; const SKILL_LISTING_TITLE: &str = "# Skill Listing"; @@ -261,6 +262,55 @@ pub struct WorkspaceContextFacts { pub workspace_path: String, pub related_paths: Vec, pub remote_execution: Option, + pub worktree: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeContextFacts { + pub project_workspace_path: String, + pub execution_target: SessionExecutionTarget, +} + +fn render_worktree_context(facts: Option<&WorktreeContextFacts>) -> String { + let Some(facts) = facts else { + return String::new(); + }; + let target = &facts.execution_target; + let target_label = match target.kind { + SessionExecutionTargetKind::Local => return String::new(), + SessionExecutionTargetKind::ManagedWorktree => { + "Managed Git worktree created for this session" + } + SessionExecutionTargetKind::ExistingWorktree => { + "Existing Git worktree registered for this session" + } + }; + let mut lines = vec![ + format!("- Execution target: {}", target_label), + format!( + "- Owning project root (session history and worktree management only): {}", + facts.project_workspace_path.replace('\\', "/") + ), + ]; + if let Some(worktree_id) = target.worktree_id.as_deref() { + lines.push(format!("- Worktree ID: {}", worktree_id)); + } + if let Some(branch) = target.branch.as_deref() { + lines.push(format!("- Worktree branch: {}", branch)); + } else { + lines.push("- Worktree checkout: detached HEAD".to_string()); + } + if let Some(base_ref) = target.base_ref.as_deref() { + lines.push(format!("- Worktree base ref: {}", base_ref)); + } + if let Some(base_commit) = target.base_commit.as_deref() { + lines.push(format!("- Worktree base commit: {}", base_commit)); + } + lines.push( + "- Keep file, shell, and Git operations inside the workspace root above unless the user explicitly requests otherwise." + .to_string(), + ); + lines.join("\n") } pub fn render_workspace_context(facts: &WorkspaceContextFacts) -> String { @@ -286,13 +336,14 @@ pub fn render_workspace_context(facts: &WorkspaceContextFacts) -> String { items ) }; + let worktree_section = render_worktree_context(facts.worktree.as_ref()); if let Some(remote) = &facts.remote_execution { format!( r#"## Workspace Context - Workspace root (file tools, Glob, LS, ExecCommand on workspace): {} -{} +{}{} - Execution environment: **Remote SSH** — connection "{}". - Remote host: {} (uname/kernel: {}) @@ -303,6 +354,11 @@ pub fn render_workspace_context(facts: &WorkspaceContextFacts) -> String { } else { format!("{}\n", related_paths_section) }, + if worktree_section.is_empty() { + String::new() + } else { + format!("{}\n", worktree_section) + }, remote.connection_display_name.replace('"', "'"), remote.hostname.replace('"', "'"), remote.kernel_name.replace('"', "'"), @@ -312,7 +368,7 @@ pub fn render_workspace_context(facts: &WorkspaceContextFacts) -> String { r#"## Workspace Context - Current Working Directory: {} -{} +{}{} "#, facts.workspace_path, @@ -320,6 +376,11 @@ pub fn render_workspace_context(facts: &WorkspaceContextFacts) -> String { String::new() } else { format!("\n{}", related_paths_section) + }, + if worktree_section.is_empty() { + String::new() + } else { + format!("\n{}", worktree_section) } ) } diff --git a/src/crates/execution/agent-runtime/tests/prompt_contracts.rs b/src/crates/execution/agent-runtime/tests/prompt_contracts.rs index 236a5ca591..7273e26ab0 100644 --- a/src/crates/execution/agent-runtime/tests/prompt_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/prompt_contracts.rs @@ -3,8 +3,9 @@ use bitfun_agent_runtime::prompt::{ render_user_context_reminder, render_workspace_context, PrependedPromptReminders, ProjectLayoutFacts, PromptEnvironmentFacts, PromptRelatedPath, RemoteExecutionHints, RuntimeContextFacts, RuntimeContextNeeds, RuntimeShellFacts, ToolListingSections, - UserContextPolicy, UserContextSection, WorkspaceContextFacts, + UserContextPolicy, UserContextSection, WorkspaceContextFacts, WorktreeContextFacts, }; +use bitfun_core_types::{SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle}; #[test] fn user_context_policy_preserves_order_and_deduplicates_sections() { @@ -264,6 +265,7 @@ fn workspace_and_user_context_renderers_preserve_section_shape() { description: Some("docs".to_string()), }], remote_execution: None, + worktree: None, }); assert!(local.contains("## Workspace Context")); @@ -280,6 +282,7 @@ fn workspace_and_user_context_renderers_preserve_section_shape() { kernel_name: "Linux".to_string(), hostname: "host".to_string(), }), + worktree: None, }); assert!(remote.contains( "Workspace root (file tools, Glob, LS, ExecCommand on workspace): /srv/workspace" @@ -287,6 +290,32 @@ fn workspace_and_user_context_renderers_preserve_section_shape() { assert!(remote.contains("Execution environment: **Remote SSH**")); assert!(remote.contains("**Remote SSH** — connection")); + let managed_worktree = render_workspace_context(&WorkspaceContextFacts { + workspace_path: "/managed/BitFun-wt-1".to_string(), + related_paths: Vec::new(), + remote_execution: None, + worktree: Some(WorktreeContextFacts { + project_workspace_path: "/projects/BitFun".to_string(), + execution_target: SessionExecutionTarget { + kind: SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("wt-1".to_string()), + root_path: "/managed/BitFun-wt-1".to_string(), + base_ref: Some("HEAD".to_string()), + base_commit: Some("0123456789abcdef".to_string()), + branch: None, + lifecycle: Some(WorktreeLifecycle::Managed), + }, + }), + }); + assert!(managed_worktree.contains("Managed Git worktree created for this session")); + assert!(managed_worktree.contains("Owning project root")); + assert!(managed_worktree.contains("/projects/BitFun")); + assert!(managed_worktree.contains("Worktree ID: wt-1")); + assert!(managed_worktree.contains("Worktree checkout: detached HEAD")); + assert!(managed_worktree.contains("Worktree base commit: 0123456789abcdef")); + assert!(managed_worktree + .contains("Keep file, shell, and Git operations inside the workspace root above")); + let project_layout = render_project_layout(&ProjectLayoutFacts { listing: "src\nCargo.toml".to_string(), reached_limit: true, diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx index 4869a3fa12..ea40da58c7 100644 --- a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx @@ -281,7 +281,11 @@ describe('WorktreesConfig', () => { it('requires confirmation and uses force only when local work would be discarded', async () => { listProjectsMock.mockResolvedValueOnce([{ projectWorkspacePath: '/repo', - worktrees: [worktree({ dirty: true })], + worktrees: [worktree({ + associatedSessionCount: 0, + dirty: true, + sessions: [], + })], }]); await act(async () => { @@ -311,10 +315,10 @@ describe('WorktreesConfig', () => { ); }); - it('disables deletion while a worktree still has unarchived sessions', async () => { + it('disables deletion while a worktree still has associated archived sessions', async () => { listProjectsMock.mockResolvedValueOnce([{ projectWorkspacePath: '/repo', - worktrees: [worktree({ runningSessionCount: 1 })], + worktrees: [worktree()], }]); await act(async () => { @@ -326,6 +330,6 @@ describe('WorktreesConfig', () => { .find(button => button.textContent?.includes('management.delete.action')); expect(deleteButton?.disabled).toBe(true); - expect(container.textContent).toContain('management.protection.activeSessions'); + expect(container.textContent).toContain('management.protection.associatedSessions'); }); }); diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx index 8faae38743..12d3faa30c 100644 --- a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx @@ -88,10 +88,10 @@ function createDeleteRequestId(): string { ?? `worktree-settings-delete-${Date.now()}-${Math.random()}`; } -type DeletionBlockReason = 'activeSessions' | 'locked' | 'missing'; +type DeletionBlockReason = 'associatedSessions' | 'locked' | 'missing'; function deletionBlockReason(worktree: WorktreeSummary): DeletionBlockReason | null { - if (worktree.runningSessionCount > 0) return 'activeSessions'; + if (worktree.associatedSessionCount > 0) return 'associatedSessions'; if (worktree.locked) return 'locked'; if (worktree.missing) return 'missing'; return null; @@ -208,7 +208,7 @@ const WorktreesConfig: React.FC = () => { const text = (() => { switch (code) { case 'worktree_busy': - return t('management.errors.activeSessions'); + return t('management.errors.associatedSessions'); case 'worktree_locked': return t('management.errors.locked'); case 'dirty_worktree': @@ -348,8 +348,8 @@ const WorktreesConfig: React.FC = () => { const blockCode = deletionBlockReason(worktree); const blockReason = (() => { switch (blockCode) { - case 'activeSessions': - return t('management.protection.activeSessions'); + case 'associatedSessions': + return t('management.protection.associatedSessions'); case 'locked': return t('management.protection.locked'); case 'missing': @@ -550,9 +550,7 @@ const WorktreesConfig: React.FC = () => { : t('management.delete.title')} message={deletingWithLocalWork ? t('management.delete.forceMessage') - : t('management.delete.message', { - count: deleteTarget?.worktree.associatedSessionCount ?? 0, - })} + : t('management.delete.message')} preview={deleteTarget?.worktree.path} type={deletingWithLocalWork ? 'error' : 'warning'} confirmDanger diff --git a/src/web-ui/src/locales/en-US/worktrees.json b/src/web-ui/src/locales/en-US/worktrees.json index 6b3d027107..e5245ae1df 100644 --- a/src/web-ui/src/locales/en-US/worktrees.json +++ b/src/web-ui/src/locales/en-US/worktrees.json @@ -43,7 +43,7 @@ }, "autoDeleteLimit": { "label": "Auto-delete limit", - "description": "Managed worktrees to keep per workspace. Worktrees with unarchived sessions, local changes, unpublished commits, or Git locks are always kept.", + "description": "Managed worktrees to keep per workspace. Worktrees with associated conversations, local changes, unpublished commits, or Git locks are always kept.", "invalid": "Enter a number from {{min}} to {{max}}." } }, @@ -81,7 +81,7 @@ } }, "protection": { - "activeSessions": "Archive or move all associated sessions before deleting this worktree.", + "associatedSessions": "Delete all associated conversations before deleting this worktree.", "locked": "Unlock this worktree in Git before deleting it.", "missing": "The worktree directory is missing. Clean up the stale Git record manually." }, @@ -89,7 +89,7 @@ "action": "Delete", "actionLabel": "Delete worktree {{path}}", "title": "Delete this worktree?", - "message": "Associated conversations remain in history ({{count}}). The checkout will be removed.", + "message": "The checkout will be removed. This action cannot be undone.", "forceTitle": "Delete and discard local work?", "forceMessage": "This worktree contains local changes or detached commits. Deleting it will permanently discard that local work.", "forceHint": "Deleting this worktree requires confirmation to discard its local work.", @@ -97,7 +97,7 @@ "cancel": "Cancel" }, "errors": { - "activeSessions": "This worktree has unarchived sessions and cannot be deleted.", + "associatedSessions": "This worktree has associated conversations and cannot be deleted.", "locked": "This worktree is locked by Git and cannot be deleted.", "dirty": "This worktree still contains local changes.", "unpublishedCommits": "This worktree still contains unpublished detached commits.", diff --git a/src/web-ui/src/locales/zh-CN/worktrees.json b/src/web-ui/src/locales/zh-CN/worktrees.json index 807e5b8234..e950460ec4 100644 --- a/src/web-ui/src/locales/zh-CN/worktrees.json +++ b/src/web-ui/src/locales/zh-CN/worktrees.json @@ -43,7 +43,7 @@ }, "autoDeleteLimit": { "label": "自动删除上限", - "description": "每个工作区保留的托管 worktree 数量。包含未归档会话、本地改动、未发布提交或 Git 锁的 worktree 始终会被保留。", + "description": "每个工作区保留的托管 worktree 数量。包含关联会话、本地改动、未发布提交或 Git 锁的 worktree 始终会被保留。", "invalid": "请输入 {{min}} 到 {{max}} 之间的数字。" } }, @@ -81,7 +81,7 @@ } }, "protection": { - "activeSessions": "请先归档或移动所有关联会话,再删除此 worktree。", + "associatedSessions": "请先删除所有关联会话,再删除此 worktree。", "locked": "请先在 Git 中解锁此 worktree,再执行删除。", "missing": "Worktree 目录已缺失,请手动清理过期的 Git 记录。" }, @@ -89,7 +89,7 @@ "action": "删除", "actionLabel": "删除 Worktree {{path}}", "title": "删除此 Worktree?", - "message": "关联会话会保留在历史记录中({{count}} 个),当前检出目录将被删除。", + "message": "当前检出目录将被删除,此操作无法撤销。", "forceTitle": "删除并丢弃本地工作?", "forceMessage": "此 worktree 包含本地改动或游离提交。删除后,这些本地工作将永久丢失。", "forceHint": "删除此 worktree 需要再次确认,并会丢弃其中的本地工作。", @@ -97,7 +97,7 @@ "cancel": "取消" }, "errors": { - "activeSessions": "此 worktree 包含未归档会话,无法删除。", + "associatedSessions": "此 worktree 包含关联会话,无法删除。", "locked": "此 worktree 已被 Git 锁定,无法删除。", "dirty": "此 worktree 仍包含本地改动。", "unpublishedCommits": "此 worktree 仍包含未发布的游离提交。", diff --git a/src/web-ui/src/locales/zh-TW/worktrees.json b/src/web-ui/src/locales/zh-TW/worktrees.json index aed3c9119d..840943dc47 100644 --- a/src/web-ui/src/locales/zh-TW/worktrees.json +++ b/src/web-ui/src/locales/zh-TW/worktrees.json @@ -43,7 +43,7 @@ }, "autoDeleteLimit": { "label": "自動刪除上限", - "description": "每個工作區保留的託管 worktree 數量。包含未封存工作階段、本機變更、未發布提交或 Git 鎖定的 worktree 一律會保留。", + "description": "每個工作區保留的託管 worktree 數量。包含關聯工作階段、本機變更、未發布提交或 Git 鎖定的 worktree 一律會保留。", "invalid": "請輸入 {{min}} 到 {{max}} 之間的數字。" } }, @@ -81,7 +81,7 @@ } }, "protection": { - "activeSessions": "請先封存或移動所有關聯工作階段,再刪除此 worktree。", + "associatedSessions": "請先刪除所有關聯工作階段,再刪除此 worktree。", "locked": "請先在 Git 中解鎖此 worktree,再執行刪除。", "missing": "Worktree 目錄已遺失,請手動清理過期的 Git 記錄。" }, @@ -89,7 +89,7 @@ "action": "刪除", "actionLabel": "刪除 Worktree {{path}}", "title": "刪除此 Worktree?", - "message": "關聯工作階段會保留在歷史記錄中({{count}} 個),目前的簽出目錄將被刪除。", + "message": "目前的簽出目錄將被刪除,此操作無法復原。", "forceTitle": "刪除並捨棄本機工作?", "forceMessage": "此 worktree 包含本機變更或游離提交。刪除後,這些本機工作將永久遺失。", "forceHint": "刪除此 worktree 需要再次確認,並會捨棄其中的本機工作。", @@ -97,7 +97,7 @@ "cancel": "取消" }, "errors": { - "activeSessions": "此 worktree 包含未封存工作階段,無法刪除。", + "associatedSessions": "此 worktree 包含關聯工作階段,無法刪除。", "locked": "此 worktree 已被 Git 鎖定,無法刪除。", "dirty": "此 worktree 仍包含本機變更。", "unpublishedCommits": "此 worktree 仍包含未發布的游離提交。",