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 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 #[cfg(unix)]
107 std::os::unix::fs::symlink(target_dir.path(), dir.path().join("link")).unwrap();
108 #[cfg(not(unix))]
109 {
110 return;
112 }
113
114 let removed = remove_dir_contents(dir.path()).unwrap();
115 assert_eq!(removed, 1);
116 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 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 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}