Skip to main content

oven_cli/cli/
clean.rs

1use anyhow::{Context, Result};
2
3use super::{CleanArgs, GlobalOpts};
4use crate::{db, git};
5
6pub async fn run(args: CleanArgs, _global: &GlobalOpts) -> Result<()> {
7    let project_dir = std::env::current_dir().context("getting current directory")?;
8    let all = !args.only_logs && !args.only_trees && !args.only_branches;
9
10    if all || args.only_trees {
11        let pruned = git::clean_worktrees(&project_dir).await?;
12        println!("pruned {pruned} worktree(s)");
13
14        let worktree_dir = project_dir.join(".oven").join("worktrees");
15        if worktree_dir.exists() {
16            let removed = remove_dir_contents(&worktree_dir)?;
17            println!("removed {removed} worktree dir(s)");
18        }
19    }
20
21    if all || args.only_logs {
22        let logs_dir = project_dir.join(".oven").join("logs");
23        if logs_dir.exists() {
24            let db_path = project_dir.join(".oven").join("oven.db");
25            let removed = if db_path.exists() {
26                let conn = db::open(&db_path)?;
27                remove_completed_logs(&conn, &logs_dir)?
28            } else {
29                remove_dir_contents(&logs_dir)?
30            };
31            println!("removed {removed} log dir(s)");
32        }
33    }
34
35    if all || args.only_branches {
36        let base = git::default_branch(&project_dir).await?;
37        let branches = git::list_merged_branches(&project_dir, &base).await?;
38        let count = branches.len();
39        for branch in branches {
40            git::delete_branch(&project_dir, &branch).await?;
41        }
42        println!("deleted {count} merged branch(es)");
43    }
44
45    Ok(())
46}
47
48fn remove_dir_contents(dir: &std::path::Path) -> Result<u32> {
49    let mut count = 0u32;
50    for entry in std::fs::read_dir(dir).context("reading directory")? {
51        let entry = entry?;
52        let path = entry.path();
53        let file_type = entry.file_type().with_context(|| format!("stat {}", path.display()))?;
54        // Remove symlinks directly without following them to avoid deleting
55        // targets outside the project directory.
56        if file_type.is_symlink() || file_type.is_file() {
57            std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
58        } else if file_type.is_dir() {
59            std::fs::remove_dir_all(&path)
60                .with_context(|| format!("removing {}", path.display()))?;
61        }
62        count += 1;
63    }
64    Ok(count)
65}
66
67fn remove_completed_logs(conn: &rusqlite::Connection, logs_dir: &std::path::Path) -> Result<u32> {
68    let completed_runs = db::runs::get_runs_by_status(conn, db::RunStatus::Complete)?;
69    let failed_runs = db::runs::get_runs_by_status(conn, db::RunStatus::Failed)?;
70
71    let mut count = 0u32;
72    for run in completed_runs.iter().chain(failed_runs.iter()) {
73        let log_path = logs_dir.join(&run.id);
74        if log_path.exists() {
75            std::fs::remove_dir_all(&log_path)
76                .with_context(|| format!("removing logs for run {}", run.id))?;
77            count += 1;
78        }
79    }
80    Ok(count)
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn remove_dir_contents_cleans_files() {
89        let dir = tempfile::tempdir().unwrap();
90        std::fs::write(dir.path().join("a.txt"), "a").unwrap();
91        std::fs::write(dir.path().join("b.txt"), "b").unwrap();
92        std::fs::create_dir(dir.path().join("subdir")).unwrap();
93
94        let removed = remove_dir_contents(dir.path()).unwrap();
95        assert_eq!(removed, 3);
96        assert!(std::fs::read_dir(dir.path()).unwrap().next().is_none());
97    }
98
99    #[test]
100    fn remove_dir_contents_removes_symlink_not_target() {
101        let dir = tempfile::tempdir().unwrap();
102        let target_dir = tempfile::tempdir().unwrap();
103        std::fs::write(target_dir.path().join("important.txt"), "keep me").unwrap();
104
105        // Create a symlink inside dir pointing to target_dir
106        #[cfg(unix)]
107        std::os::unix::fs::symlink(target_dir.path(), dir.path().join("link")).unwrap();
108        #[cfg(not(unix))]
109        {
110            // Skip this test on non-Unix platforms
111            return;
112        }
113
114        let removed = remove_dir_contents(dir.path()).unwrap();
115        assert_eq!(removed, 1);
116        // The symlink is removed but the target's contents survive
117        assert!(target_dir.path().join("important.txt").exists());
118    }
119
120    #[test]
121    fn remove_dir_contents_empty_dir() {
122        let dir = tempfile::tempdir().unwrap();
123        let removed = remove_dir_contents(dir.path()).unwrap();
124        assert_eq!(removed, 0);
125    }
126
127    #[test]
128    fn remove_completed_logs_only_removes_finished() {
129        let dir = tempfile::tempdir().unwrap();
130        let logs_dir = dir.path().join("logs");
131        std::fs::create_dir_all(&logs_dir).unwrap();
132        std::fs::create_dir(logs_dir.join("run1")).unwrap();
133        std::fs::create_dir(logs_dir.join("run2")).unwrap();
134        std::fs::create_dir(logs_dir.join("run3")).unwrap();
135
136        let conn = db::open_in_memory().unwrap();
137        // Insert runs with different statuses
138        db::runs::insert_run(
139            &conn,
140            &db::Run {
141                id: "run1".to_string(),
142                issue_number: 1,
143                status: db::RunStatus::Complete,
144                pr_number: None,
145                branch: None,
146                worktree_path: None,
147                cost_usd: 0.0,
148                auto_merge: false,
149                started_at: "2026-03-12T00:00:00".to_string(),
150                finished_at: None,
151                error_message: None,
152                complexity: "full".to_string(),
153                issue_source: "github".to_string(),
154            },
155        )
156        .unwrap();
157        db::runs::insert_run(
158            &conn,
159            &db::Run {
160                id: "run2".to_string(),
161                issue_number: 2,
162                status: db::RunStatus::Implementing,
163                pr_number: None,
164                branch: None,
165                worktree_path: None,
166                cost_usd: 0.0,
167                auto_merge: false,
168                started_at: "2026-03-12T00:00:00".to_string(),
169                finished_at: None,
170                error_message: None,
171                complexity: "full".to_string(),
172                issue_source: "github".to_string(),
173            },
174        )
175        .unwrap();
176        db::runs::insert_run(
177            &conn,
178            &db::Run {
179                id: "run3".to_string(),
180                issue_number: 3,
181                status: db::RunStatus::Failed,
182                pr_number: None,
183                branch: None,
184                worktree_path: None,
185                cost_usd: 0.0,
186                auto_merge: false,
187                started_at: "2026-03-12T00:00:00".to_string(),
188                finished_at: None,
189                error_message: None,
190                complexity: "full".to_string(),
191                issue_source: "github".to_string(),
192            },
193        )
194        .unwrap();
195
196        let removed = remove_completed_logs(&conn, &logs_dir).unwrap();
197        // run1 (complete) and run3 (failed) should be removed, run2 (implementing) stays
198        assert_eq!(removed, 2);
199        assert!(!logs_dir.join("run1").exists());
200        assert!(logs_dir.join("run2").exists());
201        assert!(!logs_dir.join("run3").exists());
202    }
203}