Vectrum
Sign In

Worktree cleanup rules, like T3 Code's Storage settings

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>
JjohannSocommitted 1 parent f01825acommit1b02949

Showing 5 changed files with 193 additions and 3 deletions.

+29−0src/session.rsView File
@@ -892,6 +892,35 @@ impl Session {
892892 }
893893 }
894894
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+
895924 if let Some(plan) = self.pending_worktree.take() {
896925 self.status = SessionStatus::Working;
897926 self.preparing = true;
+13−0src/settings.rsView File
@@ -369,6 +369,15 @@ pub struct Settings {
369369 pub branch_instructions: String,
370370 /// Where new worktrees go; empty is the app's data folder.
371371 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,
372381 /// Keep the branch and worktree strip under the composer once a thread
373382 /// has started, not only on new threads.
374383 pub composer_context: bool,
@@ -516,6 +525,10 @@ impl Default for Settings {
516525 branch_prefix: "vectrum".into(),
517526 branch_instructions: String::new(),
518527 worktrees_dir: String::new(),
528+ cleanup_deleted_worktrees: false,
529+ cleanup_inactive_worktrees: None,
530+ cleanup_merged_worktrees: false,
531+ cleanup_unchanged_worktrees: false,
519532 composer_context: false,
520533 plan_mode: false,
521534 context_meter: false,
+66−2src/ui/settings/storage.rsView File
@@ -10,15 +10,17 @@ use gpui_kit::component::IconName;
1010 use gpui_kit::*;
1111
1212 use super::SettingsView;
13- use super::widgets::{Section, SettingRow, reset, text_field};
13+ use super::widgets::{Section, SettingRow, reset, stepper, switch, text_field};
1414 use crate::settings;
1515 use crate::ui::chat::display_path;
16- use crate::ui::metal::MetalButton;
16+ use crate::ui::metal::{self, MetalButton, Palette};
1717 use crate::ui::workspace;
1818
1919 impl SettingsView {
2020 pub(super) fn render_storage(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
2121 let view = cx.weak_entity();
22+ let p = metal::palette(cx);
23+ let saved = settings::load();
2224 let data = settings::data_dir();
2325 let default_worktrees = data.as_ref().map(|data| data.join("worktrees"));
2426 let worktrees = Section::new("Worktrees")
@@ -38,6 +40,44 @@ impl SettingsView {
3840 .reset(reset(&view, |s| s.worktrees_dir.clone(), |s, dir| s.worktrees_dir = dir))
3941 .control(text_field(&self.worktrees_dir, 288., window, cx)),
4042 )
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+ )
4181 .render(cx);
4282
4383 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) {
83123 std::thread::spawn(move || child.wait());
84124 }
85125 }
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+ }
+77−1src/ui/workspace.rsView File
@@ -194,6 +194,8 @@ struct Activity {
194194 }
195195
196196 pub struct Workspace {
197+ /// When Settings → Storage's worktree cleanup last ran.
198+ last_worktree_cleanup: Option<Instant>,
197199 /// The open thread's checkout as the git actions see it.
198200 git_status: Option<(PathBuf, git::GitStatus)>,
199201 /// The git action running, and since when.
@@ -345,6 +347,7 @@ impl Workspace {
345347 let editor = editors.iter().position(|app| Some(app.name) == saved_editor.as_deref()).unwrap_or(0);
346348 let now = Instant::now();
347349 let mut workspace = Self {
350+ last_worktree_cleanup: None,
348351 git_status: None,
349352 git_running: None,
350353 hover_card_hidden: None,
@@ -693,6 +696,72 @@ impl Workspace {
693696 self.last_pr_check = Some(Instant::now());
694697 self.check_pull_requests(cx);
695698 }
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();
696765 }
697766
698767 /// Looks up the pull requests of worktree threads' branches, and settles
@@ -1387,7 +1456,14 @@ impl Workspace {
13871456 browser::stop(&browser::session_name(&id));
13881457 })
13891458 .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 {
13911467 let name = worktree.root.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_default();
13921468 let message =
13931469 format!("This thread is the only one linked to this worktree:\n{name}\n\nDelete the worktree too?");
+8−0src/worktree.rsView File
@@ -223,6 +223,14 @@ pub fn remove(worktree: &Worktree) -> Result<(), String> {
223223 git(&worktree.repo, &["worktree", "remove", &root]).map(|_| ())
224224 }
225225
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+
226234 /// Removes a worktree whose last thread was deleted, local changes and
227235 /// all, as T3 Code does once the user agreed; a worktree that's already
228236 /// gone is pruned. The branch stays.