Settings → Storage → Worktrees adds T3's four rules: delete a deleted thread's worktree without asking (unless it has local changes), delete worktrees after their thread has been inactive for some days, delete worktrees whose pull request merged into the default branch, and delete worktrees with no commits beyond it. Cleanup only touches worktrees in the app's worktree folder, with nothing uncommitted, on their thread's branch and with no agent at work; the thread and its branch stay, and the worktree comes back from the branch with the thread's next message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 5 changed files with 193 additions and 3 deletions.
| @@ -892,6 +892,35 @@ impl Session { | ||
| 892 | 892 | } |
| 893 | 893 | } |
| 894 | 894 | |
| 895 | + if let Some(worktree) = self.worktree.clone().filter(|worktree| !worktree.root.exists()) { | |
| 896 | + // Settings → Storage's cleanup removed it: bring it back from its | |
| 897 | + // branch, then carry on there. | |
| 898 | + self.status = SessionStatus::Working; | |
| 899 | + self.preparing = true; | |
| 900 | + self.turn_started = Some(Instant::now()); | |
| 901 | + self.turn_cost = 0.; | |
| 902 | + let task = cx.background_executor().spawn(async move { worktree::restore(&worktree) }); | |
| 903 | + cx.spawn(async move |this, cx| { | |
| 904 | + let restored = task.await; | |
| 905 | + let _ = this.update(cx, |this, cx| { | |
| 906 | + this.preparing = false; | |
| 907 | + if let Err(err) = restored { | |
| 908 | + this.push_notice(NoticeKind::Error, format!("Couldn't bring the worktree back: {err}")); | |
| 909 | + } | |
| 910 | + // The agent ran in the removed folder; it resumes in the new one. | |
| 911 | + this.process.take(); | |
| 912 | + this._events_task = Task::ready(()); | |
| 913 | + this.pending_controls.clear(); | |
| 914 | + this.snapshot_base(cx); | |
| 915 | + this.deliver(text, attachments, cx); | |
| 916 | + cx.notify(); | |
| 917 | + }); | |
| 918 | + }) | |
| 919 | + .detach(); | |
| 920 | + cx.notify(); | |
| 921 | + return; | |
| 922 | + } | |
| 923 | + | |
| 895 | 924 | if let Some(plan) = self.pending_worktree.take() { |
| 896 | 925 | self.status = SessionStatus::Working; |
| 897 | 926 | self.preparing = true; |
| @@ -369,6 +369,15 @@ pub struct Settings { | ||
| 369 | 369 | pub branch_instructions: String, |
| 370 | 370 | /// Where new worktrees go; empty is the app's data folder. |
| 371 | 371 | pub worktrees_dir: String, |
| 372 | + /// Remove a deleted thread's worktree without asking, unless it has | |
| 373 | + /// local changes. | |
| 374 | + pub cleanup_deleted_worktrees: bool, | |
| 375 | + /// Remove a worktree once its thread has been inactive this many days. | |
| 376 | + pub cleanup_inactive_worktrees: Option<u32>, | |
| 377 | + /// Remove a worktree whose pull request merged into the default branch. | |
| 378 | + pub cleanup_merged_worktrees: bool, | |
| 379 | + /// Remove a worktree with no commits beyond the default branch. | |
| 380 | + pub cleanup_unchanged_worktrees: bool, | |
| 372 | 381 | /// Keep the branch and worktree strip under the composer once a thread |
| 373 | 382 | /// has started, not only on new threads. |
| 374 | 383 | pub composer_context: bool, |
| @@ -516,6 +525,10 @@ impl Default for Settings { | ||
| 516 | 525 | branch_prefix: "vectrum".into(), |
| 517 | 526 | branch_instructions: String::new(), |
| 518 | 527 | worktrees_dir: String::new(), |
| 528 | + cleanup_deleted_worktrees: false, | |
| 529 | + cleanup_inactive_worktrees: None, | |
| 530 | + cleanup_merged_worktrees: false, | |
| 531 | + cleanup_unchanged_worktrees: false, | |
| 519 | 532 | composer_context: false, |
| 520 | 533 | plan_mode: false, |
| 521 | 534 | context_meter: false, |
| @@ -10,15 +10,17 @@ use gpui_kit::component::IconName; | ||
| 10 | 10 | use gpui_kit::*; |
| 11 | 11 | |
| 12 | 12 | use super::SettingsView; |
| 13 | - use super::widgets::{Section, SettingRow, reset, text_field}; | |
| 13 | + use super::widgets::{Section, SettingRow, reset, stepper, switch, text_field}; | |
| 14 | 14 | use crate::settings; |
| 15 | 15 | use crate::ui::chat::display_path; |
| 16 | - use crate::ui::metal::MetalButton; | |
| 16 | + use crate::ui::metal::{self, MetalButton, Palette}; | |
| 17 | 17 | use crate::ui::workspace; |
| 18 | 18 | |
| 19 | 19 | impl SettingsView { |
| 20 | 20 | pub(super) fn render_storage(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> { |
| 21 | 21 | let view = cx.weak_entity(); |
| 22 | + let p = metal::palette(cx); | |
| 23 | + let saved = settings::load(); | |
| 22 | 24 | let data = settings::data_dir(); |
| 23 | 25 | let default_worktrees = data.as_ref().map(|data| data.join("worktrees")); |
| 24 | 26 | let worktrees = Section::new("Worktrees") |
| @@ -38,6 +40,44 @@ impl SettingsView { | ||
| 38 | 40 | .reset(reset(&view, |s| s.worktrees_dir.clone(), |s, dir| s.worktrees_dir = dir)) |
| 39 | 41 | .control(text_field(&self.worktrees_dir, 288., window, cx)), |
| 40 | 42 | ) |
| 43 | + .row( | |
| 44 | + SettingRow::new("Delete worktrees with deleted threads") | |
| 45 | + .id("storage-worktrees-deleted") | |
| 46 | + .description( | |
| 47 | + "Remove unused worktrees when active or archived threads are deleted. Worktrees with local changes are kept.", | |
| 48 | + ) | |
| 49 | + .reset(reset(&view, |s| s.cleanup_deleted_worktrees, |s, on| s.cleanup_deleted_worktrees = on)) | |
| 50 | + .control(switch("cleanup-deleted", saved.cleanup_deleted_worktrees, &view, p, |settings, on| { | |
| 51 | + settings.cleanup_deleted_worktrees = on | |
| 52 | + })), | |
| 53 | + ) | |
| 54 | + .row( | |
| 55 | + SettingRow::new("Delete inactive worktrees") | |
| 56 | + .id("storage-worktrees-inactive") | |
| 57 | + .description( | |
| 58 | + "Remove worktrees after their threads have been inactive for this many days. Branches and thread history are kept.", | |
| 59 | + ) | |
| 60 | + .reset(reset(&view, |s| s.cleanup_inactive_worktrees, |s, days| s.cleanup_inactive_worktrees = days)) | |
| 61 | + .control(retention("cleanup-inactive", saved.cleanup_inactive_worktrees, &view, p)), | |
| 62 | + ) | |
| 63 | + .row( | |
| 64 | + SettingRow::new("Delete merged worktrees") | |
| 65 | + .id("storage-worktrees-merged") | |
| 66 | + .description("Remove worktrees whose pull request is merged and whose commits are included in the default branch.") | |
| 67 | + .reset(reset(&view, |s| s.cleanup_merged_worktrees, |s, on| s.cleanup_merged_worktrees = on)) | |
| 68 | + .control(switch("cleanup-merged", saved.cleanup_merged_worktrees, &view, p, |settings, on| { | |
| 69 | + settings.cleanup_merged_worktrees = on | |
| 70 | + })), | |
| 71 | + ) | |
| 72 | + .row( | |
| 73 | + SettingRow::new("Delete unchanged worktrees") | |
| 74 | + .id("storage-worktrees-unchanged") | |
| 75 | + .description("Remove worktrees with no commits beyond the default branch.") | |
| 76 | + .reset(reset(&view, |s| s.cleanup_unchanged_worktrees, |s, on| s.cleanup_unchanged_worktrees = on)) | |
| 77 | + .control(switch("cleanup-unchanged", saved.cleanup_unchanged_worktrees, &view, p, |settings, on| { | |
| 78 | + settings.cleanup_unchanged_worktrees = on | |
| 79 | + })), | |
| 80 | + ) | |
| 41 | 81 | .render(cx); |
| 42 | 82 | |
| 43 | 83 | let settings_dir = settings::path().as_deref().and_then(Path::parent).map(Path::to_path_buf); |
| @@ -83,3 +123,27 @@ pub(super) fn open_folder(path: &Path) { | ||
| 83 | 123 | std::thread::spawn(move || child.wait()); |
| 84 | 124 | } |
| 85 | 125 | } |
| 126 | + | |
| 127 | + /// T3 Code's retention control: a switch, and once it's on, how many days. | |
| 128 | + fn retention(id: &'static str, days: Option<u32>, view: &WeakEntity<SettingsView>, p: &Palette) -> AnyElement { | |
| 129 | + let toggle = metal::toggle(SharedString::from(format!("{id}-toggle")), days.is_some(), p, { | |
| 130 | + let view = view.clone(); | |
| 131 | + move |on, window, cx| { | |
| 132 | + super::widgets::edit(&view, window, cx, |settings| settings.cleanup_inactive_worktrees = on.then_some(8)); | |
| 133 | + } | |
| 134 | + }); | |
| 135 | + match days { | |
| 136 | + None => gpui_kit::component::h_flex() | |
| 137 | + .gap(px(8.)) | |
| 138 | + .child(div().text_size(px(12.)).text_color(p.ink3).child("Off")) | |
| 139 | + .child(toggle) | |
| 140 | + .into_any_element(), | |
| 141 | + Some(days) => gpui_kit::component::h_flex() | |
| 142 | + .gap(px(8.)) | |
| 143 | + .child(stepper(id, u64::from(days), 1, (1, 3650), "days", view, p, |settings, days| { | |
| 144 | + settings.cleanup_inactive_worktrees = Some(days as u32) | |
| 145 | + })) | |
| 146 | + .child(toggle) | |
| 147 | + .into_any_element(), | |
| 148 | + } | |
| 149 | + } | |
| @@ -194,6 +194,8 @@ struct Activity { | ||
| 194 | 194 | } |
| 195 | 195 | |
| 196 | 196 | pub struct Workspace { |
| 197 | + /// When Settings → Storage's worktree cleanup last ran. | |
| 198 | + last_worktree_cleanup: Option<Instant>, | |
| 197 | 199 | /// The open thread's checkout as the git actions see it. |
| 198 | 200 | git_status: Option<(PathBuf, git::GitStatus)>, |
| 199 | 201 | /// The git action running, and since when. |
| @@ -345,6 +347,7 @@ impl Workspace { | ||
| 345 | 347 | let editor = editors.iter().position(|app| Some(app.name) == saved_editor.as_deref()).unwrap_or(0); |
| 346 | 348 | let now = Instant::now(); |
| 347 | 349 | let mut workspace = Self { |
| 350 | + last_worktree_cleanup: None, | |
| 348 | 351 | git_status: None, |
| 349 | 352 | git_running: None, |
| 350 | 353 | hover_card_hidden: None, |
| @@ -693,6 +696,72 @@ impl Workspace { | ||
| 693 | 696 | self.last_pr_check = Some(Instant::now()); |
| 694 | 697 | self.check_pull_requests(cx); |
| 695 | 698 | } |
| 699 | + if allowed && self.last_worktree_cleanup.is_none_or(|checked| checked.elapsed() >= PR_CHECK) { | |
| 700 | + self.last_worktree_cleanup = Some(Instant::now()); | |
| 701 | + self.clean_up_worktrees(cx); | |
| 702 | + } | |
| 703 | + } | |
| 704 | + | |
| 705 | + /// Settings → Storage's worktree cleanup: removes the worktrees of | |
| 706 | + /// threads inactive for a while, whose pull request merged, or with no | |
| 707 | + /// commits beyond the default branch. Like T3 Code, only worktrees in the | |
| 708 | + /// app's worktree folder, with nothing uncommitted, on their thread's | |
| 709 | + /// branch, and with no agent at work there; the thread and its branch | |
| 710 | + /// stay, and the worktree comes back with the thread's next message. | |
| 711 | + fn clean_up_worktrees(&mut self, cx: &mut Context<Self>) { | |
| 712 | + let (inactive_days, merged, unchanged) = settings::get(|settings| { | |
| 713 | + (settings.cleanup_inactive_worktrees, settings.cleanup_merged_worktrees, settings.cleanup_unchanged_worktrees) | |
| 714 | + }); | |
| 715 | + if inactive_days.is_none() && !merged && !unchanged { | |
| 716 | + return; | |
| 717 | + } | |
| 718 | + let Some(managed) = worktree::worktrees_dir() else { return }; | |
| 719 | + let now = SystemTime::now(); | |
| 720 | + let sessions: Vec<&Entity<Session>> = | |
| 721 | + self.projects.iter().filter(|project| project.environment.is_none()).flat_map(|project| &project.sessions).collect(); | |
| 722 | + let busy: Vec<PathBuf> = sessions | |
| 723 | + .iter() | |
| 724 | + .map(|session| session.read(cx)) | |
| 725 | + .filter(|state| state.is_working() || state.preparing) | |
| 726 | + .filter_map(|state| state.worktree.as_ref().map(|worktree| worktree.root.clone())) | |
| 727 | + .collect(); | |
| 728 | + let mut candidates: Vec<(Worktree, bool, bool)> = Vec::new(); | |
| 729 | + for session in sessions { | |
| 730 | + let state = session.read(cx); | |
| 731 | + let Some(worktree) = state.worktree.clone() else { continue }; | |
| 732 | + if !worktree.root.starts_with(&managed) || !worktree.root.exists() || busy.contains(&worktree.root) { | |
| 733 | + continue; | |
| 734 | + } | |
| 735 | + let inactive = inactive_days.is_some_and(|days| { | |
| 736 | + now.duration_since(state.updated).unwrap_or_default() >= Duration::from_secs(u64::from(days) * 86_400) | |
| 737 | + }); | |
| 738 | + let pr_merged = merged && state.pull_request.as_ref().is_some_and(|pr| pr.state == "MERGED"); | |
| 739 | + if inactive || pr_merged || unchanged { | |
| 740 | + candidates.push((worktree, inactive || pr_merged, pr_merged)); | |
| 741 | + } | |
| 742 | + } | |
| 743 | + if candidates.is_empty() { | |
| 744 | + return; | |
| 745 | + } | |
| 746 | + cx.background_executor() | |
| 747 | + .spawn(async move { | |
| 748 | + for (worktree, due, merged) in candidates { | |
| 749 | + let status = git::status(&worktree.root); | |
| 750 | + let safe = status | |
| 751 | + .as_ref() | |
| 752 | + .is_some_and(|status| !status.has_changes() && status.branch.as_deref() == Some(&worktree.branch)); | |
| 753 | + if !safe { | |
| 754 | + continue; | |
| 755 | + } | |
| 756 | + let status = status.unwrap_or_default(); | |
| 757 | + // A merged branch's commits must be in the default branch. | |
| 758 | + let merged_in = !merged || status.ahead_of_default == 0; | |
| 759 | + if (due && merged_in) || (unchanged && status.ahead_of_default == 0) { | |
| 760 | + let _ = worktree::remove(&worktree); | |
| 761 | + } | |
| 762 | + } | |
| 763 | + }) | |
| 764 | + .detach(); | |
| 696 | 765 | } |
| 697 | 766 | |
| 698 | 767 | /// Looks up the pull requests of worktree threads' branches, and settles |
| @@ -1387,7 +1456,14 @@ impl Workspace { | ||
| 1387 | 1456 | browser::stop(&browser::session_name(&id)); |
| 1388 | 1457 | }) |
| 1389 | 1458 | .detach(); |
| 1390 | - if let Some(worktree) = worktree { | |
| 1459 | + if let Some(worktree) = worktree.clone().filter(|_| settings::get(|settings| settings.cleanup_deleted_worktrees)) { | |
| 1460 | + // Settings → Storage says to; git keeps one with local changes. | |
| 1461 | + cx.background_executor() | |
| 1462 | + .spawn(async move { | |
| 1463 | + let _ = worktree::remove(&worktree); | |
| 1464 | + }) | |
| 1465 | + .detach(); | |
| 1466 | + } else if let Some(worktree) = worktree { | |
| 1391 | 1467 | let name = worktree.root.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_default(); |
| 1392 | 1468 | let message = |
| 1393 | 1469 | format!("This thread is the only one linked to this worktree:\n{name}\n\nDelete the worktree too?"); |
| @@ -223,6 +223,14 @@ pub fn remove(worktree: &Worktree) -> Result<(), String> { | ||
| 223 | 223 | git(&worktree.repo, &["worktree", "remove", &root]).map(|_| ()) |
| 224 | 224 | } |
| 225 | 225 | |
| 226 | + /// Brings back a worktree that cleanup removed, from its branch, in the | |
| 227 | + /// same folder. | |
| 228 | + pub fn restore(worktree: &Worktree) -> Result<(), String> { | |
| 229 | + let _ = git(&worktree.repo, &["worktree", "prune"]); | |
| 230 | + let root = worktree.root.to_string_lossy().to_string(); | |
| 231 | + git(&worktree.repo, &["worktree", "add", &root, &worktree.branch]).map(|_| ()) | |
| 232 | + } | |
| 233 | + | |
| 226 | 234 | /// Removes a worktree whose last thread was deleted, local changes and |
| 227 | 235 | /// all, as T3 Code does once the user agreed; a worktree that's already |
| 228 | 236 | /// gone is pruned. The branch stays. |