Skip to content

Commit a8cc774

Browse files
authored
Fix orphan cleanup to properly handle workspaces containing multiple worktrees (Vibe Kanban) (#1545)
* All done. Here's a summary of the changes made: Fixed the orphan cleanup bug by moving the logic from `LocalContainerService` to `WorkspaceManager` and properly handling workspaces (which contain multiple worktrees). 1. **`crates/services/src/services/workspace_manager.rs`**: - Added `cleanup_orphan_workspaces()` - public method that scans the workspace base directory for orphaned workspaces and cleans them up - Added `cleanup_workspace_without_repos()` - helper method that cleans up a workspace by discovering worktrees (subdirectories with `.git` files) and cleaning each one properly using `WorktreeManager::cleanup_worktree()` 2. **`crates/local-deployment/src/container.rs`**: - Removed the broken `cleanup_orphaned_worktrees()` method (was incorrectly treating workspaces as worktrees) - Updated `spawn_workspace_cleanup()` to call `WorkspaceManager::cleanup_orphan_workspaces()` instead - Removed unused imports (`WorktreeCleanup`, `WorktreeManager`) The old code was passing workspace paths (e.g., `/worktrees/abc-task-title/`) directly to `WorktreeManager::cleanup_worktree()`, which expected individual worktree paths (e.g., `/worktrees/abc-task-title/repo-name/`). The new code correctly: 1. Identifies orphaned **workspaces** by checking if the workspace path exists in any task attempt's `container_ref` 2. For each orphaned workspace, scans its subdirectories for worktrees (directories with `.git` files) 3. Cleans up each worktree properly using `WorktreeManager::cleanup_worktree()` 4. Finally removes the workspace directory itself * Done. I've added `cleanup_suspected_worktree()` to `WorktreeManager` and updated `cleanup_workspace_without_repos()` in `WorkspaceManager` to use it. This keeps the worktree detection and cleanup logic in the appropriate manager. * Comments * lint
1 parent 41b9bc0 commit a8cc774

File tree

4 files changed

+142
-94
lines changed

4 files changed

+142
-94
lines changed

crates/local-deployment/src/container.rs

Lines changed: 19 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ use services::services::{
5252
queued_message::QueuedMessageService,
5353
share::SharePublisher,
5454
workspace_manager::{RepoWorkspaceInput, WorkspaceManager},
55-
worktree_manager::{WorktreeCleanup, WorktreeManager},
5655
};
5756
use tokio::{sync::RwLock, task::JoinHandle};
5857
use tokio_util::io::ReaderStream;
@@ -143,73 +142,7 @@ impl LocalContainerService {
143142
map.remove(id)
144143
}
145144

146-
/// Find and delete orphaned worktrees that don't correspond to any task attempts
147-
async fn cleanup_orphaned_worktrees(&self) {
148-
// Check if orphan cleanup is disabled via environment variable
149-
if std::env::var("DISABLE_WORKTREE_ORPHAN_CLEANUP").is_ok() {
150-
tracing::debug!(
151-
"Orphan worktree cleanup is disabled via DISABLE_WORKTREE_ORPHAN_CLEANUP environment variable"
152-
);
153-
return;
154-
}
155-
let worktree_base_dir = WorktreeManager::get_worktree_base_dir();
156-
if !worktree_base_dir.exists() {
157-
tracing::debug!(
158-
"Worktree base directory {} does not exist, skipping orphan cleanup",
159-
worktree_base_dir.display()
160-
);
161-
return;
162-
}
163-
let entries = match std::fs::read_dir(&worktree_base_dir) {
164-
Ok(entries) => entries,
165-
Err(e) => {
166-
tracing::error!(
167-
"Failed to read worktree base directory {}: {}",
168-
worktree_base_dir.display(),
169-
e
170-
);
171-
return;
172-
}
173-
};
174-
for entry in entries {
175-
let entry = match entry {
176-
Ok(entry) => entry,
177-
Err(e) => {
178-
tracing::warn!("Failed to read directory entry: {}", e);
179-
continue;
180-
}
181-
};
182-
let path = entry.path();
183-
// Only process directories
184-
if !path.is_dir() {
185-
continue;
186-
}
187-
188-
let worktree_path_str = path.to_string_lossy().to_string();
189-
if let Ok(false) =
190-
TaskAttempt::container_ref_exists(&self.db().pool, &worktree_path_str).await
191-
{
192-
// This is an orphaned worktree - delete it
193-
tracing::info!("Found orphaned worktree: {}", worktree_path_str);
194-
if let Err(e) =
195-
WorktreeManager::cleanup_worktree(&WorktreeCleanup::new(path, None)).await
196-
{
197-
tracing::error!(
198-
"Failed to remove orphaned worktree {}: {}",
199-
worktree_path_str,
200-
e
201-
);
202-
} else {
203-
tracing::info!(
204-
"Successfully removed orphaned worktree: {}",
205-
worktree_path_str
206-
);
207-
}
208-
}
209-
}
210-
}
211-
212-
async fn cleanup_attempt_workspace(db: &DBService, attempt: &TaskAttempt) {
145+
pub async fn cleanup_attempt_workspace(db: &DBService, attempt: &TaskAttempt) {
213146
let Some(container_ref) = &attempt.container_ref else {
214147
return;
215148
};
@@ -224,10 +157,10 @@ impl LocalContainerService {
224157
"No repositories found for attempt {}, cleaning up workspace directory only",
225158
attempt.id
226159
);
227-
if workspace_dir.exists() {
228-
if let Err(e) = tokio::fs::remove_dir_all(&workspace_dir).await {
229-
tracing::warn!("Failed to remove workspace directory: {}", e);
230-
}
160+
if workspace_dir.exists()
161+
&& let Err(e) = tokio::fs::remove_dir_all(&workspace_dir).await
162+
{
163+
tracing::warn!("Failed to remove workspace directory: {}", e);
231164
}
232165
} else {
233166
WorkspaceManager::cleanup_workspace(&workspace_dir, &repositories)
@@ -264,7 +197,7 @@ impl LocalContainerService {
264197
pub async fn spawn_workspace_cleanup(&self) {
265198
let db = self.db.clone();
266199
let mut cleanup_interval = tokio::time::interval(tokio::time::Duration::from_secs(1800)); // 30 minutes
267-
self.cleanup_orphaned_worktrees().await;
200+
WorkspaceManager::cleanup_orphan_workspaces(&self.db.pool).await;
268201
tokio::spawn(async move {
269202
loop {
270203
cleanup_interval.tick().await;
@@ -778,19 +711,19 @@ impl LocalContainerService {
778711
let repos = AttemptRepo::find_repos_with_copy_files(&self.db.pool, task_attempt.id).await?;
779712

780713
for repo in &repos {
781-
if let Some(copy_files) = &repo.copy_files {
782-
if !copy_files.trim().is_empty() {
783-
let worktree_path = workspace_dir.join(&repo.name);
784-
self.copy_project_files(&repo.path, &worktree_path, copy_files)
785-
.await
786-
.unwrap_or_else(|e| {
787-
tracing::warn!(
788-
"Failed to copy project files for repo '{}': {}",
789-
repo.name,
790-
e
791-
);
792-
});
793-
}
714+
if let Some(copy_files) = &repo.copy_files
715+
&& !copy_files.trim().is_empty()
716+
{
717+
let worktree_path = workspace_dir.join(&repo.name);
718+
self.copy_project_files(&repo.path, &worktree_path, copy_files)
719+
.await
720+
.unwrap_or_else(|e| {
721+
tracing::warn!(
722+
"Failed to copy project files for repo '{}': {}",
723+
repo.name,
724+
e
725+
);
726+
});
794727
}
795728
}
796729

crates/local-deployment/src/copy.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,11 @@ fn copy_single_file(
101101
let target_file = target_root.join(relative_path);
102102

103103
// Skip if target exists with same size
104-
if let Ok(target_meta) = fs::metadata(&target_file) {
105-
if let Ok(source_meta) = fs::metadata(source_file) {
106-
if target_meta.len() == source_meta.len() {
107-
return Ok(false);
108-
}
109-
}
104+
if let Ok(target_meta) = fs::metadata(&target_file)
105+
&& let Ok(source_meta) = fs::metadata(source_file)
106+
&& target_meta.len() == source_meta.len()
107+
{
108+
return Ok(false);
110109
}
111110

112111
if let Some(parent) = target_file.parent()

crates/services/src/services/workspace_manager.rs

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use std::path::{Path, PathBuf};
22

3-
use db::models::repo::Repo;
3+
use db::models::{repo::Repo, task_attempt::TaskAttempt};
4+
use sqlx::{Pool, Sqlite};
45
use thiserror::Error;
5-
use tracing::{debug, error, info};
6+
use tracing::{debug, error, info, warn};
67
use uuid::Uuid;
78

89
use super::worktree_manager::{WorktreeCleanup, WorktreeError, WorktreeManager};
@@ -282,4 +283,107 @@ impl WorkspaceManager {
282283
}
283284
}
284285
}
286+
287+
pub async fn cleanup_orphan_workspaces(db: &Pool<Sqlite>) {
288+
if std::env::var("DISABLE_WORKTREE_ORPHAN_CLEANUP").is_ok() {
289+
debug!(
290+
"Orphan workspace cleanup is disabled via DISABLE_WORKTREE_ORPHAN_CLEANUP environment variable"
291+
);
292+
return;
293+
}
294+
295+
let workspace_base_dir = Self::get_workspace_base_dir();
296+
if !workspace_base_dir.exists() {
297+
debug!(
298+
"Workspace base directory {} does not exist, skipping orphan cleanup",
299+
workspace_base_dir.display()
300+
);
301+
return;
302+
}
303+
304+
let entries = match std::fs::read_dir(&workspace_base_dir) {
305+
Ok(entries) => entries,
306+
Err(e) => {
307+
error!(
308+
"Failed to read workspace base directory {}: {}",
309+
workspace_base_dir.display(),
310+
e
311+
);
312+
return;
313+
}
314+
};
315+
316+
for entry in entries {
317+
let entry = match entry {
318+
Ok(entry) => entry,
319+
Err(e) => {
320+
warn!("Failed to read directory entry: {}", e);
321+
continue;
322+
}
323+
};
324+
325+
let path = entry.path();
326+
if !path.is_dir() {
327+
continue;
328+
}
329+
330+
let workspace_path_str = path.to_string_lossy().to_string();
331+
if let Ok(false) = TaskAttempt::container_ref_exists(db, &workspace_path_str).await {
332+
info!("Found orphaned workspace: {}", workspace_path_str);
333+
if let Err(e) = Self::cleanup_workspace_without_repos(&path).await {
334+
error!(
335+
"Failed to remove orphaned workspace {}: {}",
336+
workspace_path_str, e
337+
);
338+
} else {
339+
info!(
340+
"Successfully removed orphaned workspace: {}",
341+
workspace_path_str
342+
);
343+
}
344+
}
345+
}
346+
}
347+
348+
async fn cleanup_workspace_without_repos(workspace_dir: &Path) -> Result<(), WorkspaceError> {
349+
info!(
350+
"Cleaning up orphaned workspace at {}",
351+
workspace_dir.display()
352+
);
353+
354+
let entries = match std::fs::read_dir(workspace_dir) {
355+
Ok(entries) => entries,
356+
Err(e) => {
357+
debug!(
358+
"Cannot read workspace directory {}, attempting direct removal: {}",
359+
workspace_dir.display(),
360+
e
361+
);
362+
return tokio::fs::remove_dir_all(workspace_dir)
363+
.await
364+
.map_err(WorkspaceError::Io);
365+
}
366+
};
367+
368+
for entry in entries.filter_map(|e| e.ok()) {
369+
let path = entry.path();
370+
if path.is_dir()
371+
&& let Err(e) = WorktreeManager::cleanup_suspected_worktree(&path).await
372+
{
373+
warn!("Failed to cleanup suspected worktree: {}", e);
374+
}
375+
}
376+
377+
if workspace_dir.exists()
378+
&& let Err(e) = tokio::fs::remove_dir_all(workspace_dir).await
379+
{
380+
debug!(
381+
"Could not remove workspace directory {}: {}",
382+
workspace_dir.display(),
383+
e
384+
);
385+
}
386+
387+
Ok(())
388+
}
285389
}

crates/services/src/services/worktree_manager.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,4 +518,16 @@ impl WorktreeManager {
518518
pub fn get_worktree_base_dir() -> std::path::PathBuf {
519519
utils::path::get_vibe_kanban_temp_dir().join("worktrees")
520520
}
521+
522+
pub async fn cleanup_suspected_worktree(path: &Path) -> Result<bool, WorktreeError> {
523+
let git_marker = path.join(".git");
524+
if !git_marker.exists() || !git_marker.is_file() {
525+
return Ok(false);
526+
}
527+
528+
debug!("Cleaning up suspected worktree at {}", path.display());
529+
let cleanup = WorktreeCleanup::new(path.to_path_buf(), None);
530+
Self::cleanup_worktree(&cleanup).await?;
531+
Ok(true)
532+
}
521533
}

0 commit comments

Comments
 (0)