1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use tokio::process::Command;
5
6#[derive(Debug, Clone)]
8pub struct Worktree {
9 pub path: PathBuf,
10 pub branch: String,
11 pub issue_number: u32,
12}
13
14#[derive(Debug, Clone)]
16pub struct WorktreeInfo {
17 pub path: PathBuf,
18 pub branch: Option<String>,
19}
20
21fn branch_name(issue_number: u32) -> String {
23 let short_hex = &uuid::Uuid::new_v4().to_string()[..8];
24 format!("oven/issue-{issue_number}-{short_hex}")
25}
26
27pub async fn create_worktree(
29 repo_dir: &Path,
30 issue_number: u32,
31 base_branch: &str,
32) -> Result<Worktree> {
33 let branch = branch_name(issue_number);
34 let worktree_path =
35 repo_dir.join(".oven").join("worktrees").join(format!("issue-{issue_number}"));
36
37 if let Some(parent) = worktree_path.parent() {
39 tokio::fs::create_dir_all(parent).await.context("creating worktree parent directory")?;
40 }
41
42 run_git(
43 repo_dir,
44 &["worktree", "add", "-b", &branch, &worktree_path.to_string_lossy(), base_branch],
45 )
46 .await
47 .context("creating worktree")?;
48
49 Ok(Worktree { path: worktree_path, branch, issue_number })
50}
51
52pub async fn remove_worktree(repo_dir: &Path, worktree_path: &Path) -> Result<()> {
54 run_git(repo_dir, &["worktree", "remove", "--force", &worktree_path.to_string_lossy()])
55 .await
56 .context("removing worktree")?;
57 Ok(())
58}
59
60pub async fn list_worktrees(repo_dir: &Path) -> Result<Vec<WorktreeInfo>> {
62 let output = run_git(repo_dir, &["worktree", "list", "--porcelain"])
63 .await
64 .context("listing worktrees")?;
65
66 let mut worktrees = Vec::new();
67 let mut current_path: Option<PathBuf> = None;
68 let mut current_branch: Option<String> = None;
69
70 for line in output.lines() {
71 if let Some(path_str) = line.strip_prefix("worktree ") {
72 if let Some(path) = current_path.take() {
74 worktrees.push(WorktreeInfo { path, branch: current_branch.take() });
75 }
76 current_path = Some(PathBuf::from(path_str));
77 } else if let Some(branch_ref) = line.strip_prefix("branch ") {
78 current_branch =
80 Some(branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref).to_string());
81 }
82 }
83
84 if let Some(path) = current_path {
86 worktrees.push(WorktreeInfo { path, branch: current_branch });
87 }
88
89 Ok(worktrees)
90}
91
92pub async fn clean_worktrees(repo_dir: &Path) -> Result<u32> {
94 let before = list_worktrees(repo_dir).await?;
95 run_git(repo_dir, &["worktree", "prune"]).await.context("pruning worktrees")?;
96 let after = list_worktrees(repo_dir).await?;
97
98 let pruned = if before.len() > after.len() { before.len() - after.len() } else { 0 };
99 Ok(u32::try_from(pruned).unwrap_or(u32::MAX))
100}
101
102pub async fn delete_branch(repo_dir: &Path, branch: &str) -> Result<()> {
104 run_git(repo_dir, &["branch", "-D", branch]).await.context("deleting branch")?;
105 Ok(())
106}
107
108pub async fn list_merged_branches(repo_dir: &Path, base: &str) -> Result<Vec<String>> {
110 let output = run_git(repo_dir, &["branch", "--merged", base])
111 .await
112 .context("listing merged branches")?;
113
114 let branches = output
115 .lines()
116 .map(|l| l.trim().trim_start_matches("* ").to_string())
117 .filter(|b| b.starts_with("oven/"))
118 .collect();
119
120 Ok(branches)
121}
122
123pub async fn empty_commit(repo_dir: &Path, message: &str) -> Result<()> {
125 run_git(repo_dir, &["commit", "--allow-empty", "-m", message])
126 .await
127 .context("creating empty commit")?;
128 Ok(())
129}
130
131pub async fn push_branch(repo_dir: &Path, branch: &str) -> Result<()> {
133 run_git(repo_dir, &["push", "origin", branch]).await.context("pushing branch")?;
134 Ok(())
135}
136
137pub async fn default_branch(repo_dir: &Path) -> Result<String> {
139 if let Ok(output) = run_git(repo_dir, &["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
141 if let Some(branch) = output.strip_prefix("refs/remotes/origin/") {
142 return Ok(branch.to_string());
143 }
144 }
145
146 if run_git(repo_dir, &["rev-parse", "--verify", "main"]).await.is_ok() {
148 return Ok("main".to_string());
149 }
150 if run_git(repo_dir, &["rev-parse", "--verify", "master"]).await.is_ok() {
151 return Ok("master".to_string());
152 }
153
154 let output = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
156 .await
157 .context("detecting default branch")?;
158 Ok(output)
159}
160
161async fn run_git(repo_dir: &Path, args: &[&str]) -> Result<String> {
162 let output = Command::new("git")
163 .args(args)
164 .current_dir(repo_dir)
165 .kill_on_drop(true)
166 .output()
167 .await
168 .context("spawning git")?;
169
170 if !output.status.success() {
171 let stderr = String::from_utf8_lossy(&output.stderr);
172 anyhow::bail!("git {} failed: {}", args.join(" "), stderr.trim());
173 }
174
175 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 async fn init_temp_repo() -> tempfile::TempDir {
183 let dir = tempfile::tempdir().unwrap();
184
185 Command::new("git").args(["init"]).current_dir(dir.path()).output().await.unwrap();
187
188 Command::new("git")
189 .args(["config", "user.email", "test@test.com"])
190 .current_dir(dir.path())
191 .output()
192 .await
193 .unwrap();
194
195 Command::new("git")
196 .args(["config", "user.name", "Test"])
197 .current_dir(dir.path())
198 .output()
199 .await
200 .unwrap();
201
202 tokio::fs::write(dir.path().join("README.md"), "hello").await.unwrap();
203
204 Command::new("git").args(["add", "."]).current_dir(dir.path()).output().await.unwrap();
205
206 Command::new("git")
207 .args(["commit", "-m", "initial"])
208 .current_dir(dir.path())
209 .output()
210 .await
211 .unwrap();
212
213 dir
214 }
215
216 #[tokio::test]
217 async fn create_and_remove_worktree() {
218 let dir = init_temp_repo().await;
219
220 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
222
223 let wt = create_worktree(dir.path(), 42, &branch).await.unwrap();
224 assert!(wt.path.exists());
225 assert!(wt.branch.starts_with("oven/issue-42-"));
226 assert_eq!(wt.issue_number, 42);
227
228 remove_worktree(dir.path(), &wt.path).await.unwrap();
229 assert!(!wt.path.exists());
230 }
231
232 #[tokio::test]
233 async fn list_worktrees_includes_created() {
234 let dir = init_temp_repo().await;
235 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
236
237 let _wt = create_worktree(dir.path(), 99, &branch).await.unwrap();
238
239 let worktrees = list_worktrees(dir.path()).await.unwrap();
240 assert!(worktrees.len() >= 2);
242 assert!(
243 worktrees
244 .iter()
245 .any(|w| { w.branch.as_deref().is_some_and(|b| b.starts_with("oven/issue-99-")) })
246 );
247 }
248
249 #[tokio::test]
250 async fn branch_naming_convention() {
251 let name = branch_name(123);
252 assert!(name.starts_with("oven/issue-123-"));
253 assert_eq!(name.len(), "oven/issue-123-".len() + 8);
254 let hex_part = &name["oven/issue-123-".len()..];
256 assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
257 }
258
259 #[tokio::test]
260 async fn default_branch_detection() {
261 let dir = init_temp_repo().await;
262 let branch = default_branch(dir.path()).await.unwrap();
263 assert!(branch == "main" || branch == "master", "got: {branch}");
265 }
266
267 #[tokio::test]
268 async fn error_on_non_git_dir() {
269 let dir = tempfile::tempdir().unwrap();
270 let result = list_worktrees(dir.path()).await;
271 assert!(result.is_err());
272 }
273}