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 force_push_branch(repo_dir: &Path, branch: &str) -> Result<()> {
141 let lease = format!("--force-with-lease=refs/heads/{branch}");
142 run_git(repo_dir, &["push", &lease, "origin", branch]).await.context("force-pushing branch")?;
143 Ok(())
144}
145
146pub async fn rebase_on_base(repo_dir: &Path, base_branch: &str) -> Result<()> {
151 run_git(repo_dir, &["fetch", "origin", base_branch])
152 .await
153 .context("fetching base branch before rebase")?;
154
155 let target = format!("origin/{base_branch}");
156 if run_git(repo_dir, &["rebase", &target]).await.is_ok() {
157 return Ok(());
158 }
159
160 let _ = run_git(repo_dir, &["rebase", "--abort"]).await;
161 anyhow::bail!("merge conflicts with {base_branch} that could not be automatically resolved")
162}
163
164#[derive(Debug)]
166pub enum RebaseOutcome {
167 Clean,
169 MergeFallback,
171 MergeConflicts(Vec<String>),
175 AgentResolved,
177 Failed(String),
179}
180
181pub async fn rebase_with_fallbacks(repo_dir: &Path, base_branch: &str) -> RebaseOutcome {
188 if let Err(e) = run_git(repo_dir, &["fetch", "origin", base_branch]).await {
189 return RebaseOutcome::Failed(format!("failed to fetch {base_branch}: {e}"));
190 }
191
192 let target = format!("origin/{base_branch}");
193
194 if run_git(repo_dir, &["rebase", &target]).await.is_ok() {
196 return RebaseOutcome::Clean;
197 }
198 let _ = run_git(repo_dir, &["rebase", "--abort"]).await;
199
200 if run_git(repo_dir, &["merge", &target, "--no-edit"]).await.is_ok() {
202 return RebaseOutcome::MergeFallback;
203 }
204
205 let conflicting = conflicting_files(repo_dir).await;
208 RebaseOutcome::MergeConflicts(conflicting)
209}
210
211pub async fn conflicting_files(repo_dir: &Path) -> Vec<String> {
213 run_git(repo_dir, &["diff", "--name-only", "--diff-filter=U"])
214 .await
215 .map_or_else(|_| vec![], |output| output.lines().map(String::from).collect())
216}
217
218pub async fn abort_merge(repo_dir: &Path) {
220 let _ = run_git(repo_dir, &["merge", "--abort"]).await;
221}
222
223pub async fn commit_merge(repo_dir: &Path, conflicting: &[String]) -> Result<()> {
229 for file in conflicting {
230 run_git(repo_dir, &["add", file]).await.with_context(|| format!("staging {file}"))?;
231 }
232 run_git(repo_dir, &["commit", "--no-edit"]).await.context("committing merge resolution")?;
233 Ok(())
234}
235
236pub async fn default_branch(repo_dir: &Path) -> Result<String> {
238 if let Ok(output) = run_git(repo_dir, &["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
240 if let Some(branch) = output.strip_prefix("refs/remotes/origin/") {
241 return Ok(branch.to_string());
242 }
243 }
244
245 if run_git(repo_dir, &["rev-parse", "--verify", "main"]).await.is_ok() {
247 return Ok("main".to_string());
248 }
249 if run_git(repo_dir, &["rev-parse", "--verify", "master"]).await.is_ok() {
250 return Ok("master".to_string());
251 }
252
253 let output = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
255 .await
256 .context("detecting default branch")?;
257 Ok(output)
258}
259
260async fn run_git(repo_dir: &Path, args: &[&str]) -> Result<String> {
261 let output = Command::new("git")
262 .args(args)
263 .current_dir(repo_dir)
264 .kill_on_drop(true)
265 .output()
266 .await
267 .context("spawning git")?;
268
269 if !output.status.success() {
270 let stderr = String::from_utf8_lossy(&output.stderr);
271 anyhow::bail!("git {} failed: {}", args.join(" "), stderr.trim());
272 }
273
274 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 async fn init_temp_repo() -> tempfile::TempDir {
282 let dir = tempfile::tempdir().unwrap();
283
284 Command::new("git").args(["init"]).current_dir(dir.path()).output().await.unwrap();
286
287 Command::new("git")
288 .args(["config", "user.email", "test@test.com"])
289 .current_dir(dir.path())
290 .output()
291 .await
292 .unwrap();
293
294 Command::new("git")
295 .args(["config", "user.name", "Test"])
296 .current_dir(dir.path())
297 .output()
298 .await
299 .unwrap();
300
301 tokio::fs::write(dir.path().join("README.md"), "hello").await.unwrap();
302
303 Command::new("git").args(["add", "."]).current_dir(dir.path()).output().await.unwrap();
304
305 Command::new("git")
306 .args(["commit", "-m", "initial"])
307 .current_dir(dir.path())
308 .output()
309 .await
310 .unwrap();
311
312 dir
313 }
314
315 #[tokio::test]
316 async fn create_and_remove_worktree() {
317 let dir = init_temp_repo().await;
318
319 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
321
322 let wt = create_worktree(dir.path(), 42, &branch).await.unwrap();
323 assert!(wt.path.exists());
324 assert!(wt.branch.starts_with("oven/issue-42-"));
325 assert_eq!(wt.issue_number, 42);
326
327 remove_worktree(dir.path(), &wt.path).await.unwrap();
328 assert!(!wt.path.exists());
329 }
330
331 #[tokio::test]
332 async fn list_worktrees_includes_created() {
333 let dir = init_temp_repo().await;
334 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
335
336 let _wt = create_worktree(dir.path(), 99, &branch).await.unwrap();
337
338 let worktrees = list_worktrees(dir.path()).await.unwrap();
339 assert!(worktrees.len() >= 2);
341 assert!(
342 worktrees
343 .iter()
344 .any(|w| { w.branch.as_deref().is_some_and(|b| b.starts_with("oven/issue-99-")) })
345 );
346 }
347
348 #[tokio::test]
349 async fn branch_naming_convention() {
350 let name = branch_name(123);
351 assert!(name.starts_with("oven/issue-123-"));
352 assert_eq!(name.len(), "oven/issue-123-".len() + 8);
353 let hex_part = &name["oven/issue-123-".len()..];
355 assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
356 }
357
358 #[tokio::test]
359 async fn default_branch_detection() {
360 let dir = init_temp_repo().await;
361 let branch = default_branch(dir.path()).await.unwrap();
362 assert!(branch == "main" || branch == "master", "got: {branch}");
364 }
365
366 #[tokio::test]
367 async fn error_on_non_git_dir() {
368 let dir = tempfile::tempdir().unwrap();
369 let result = list_worktrees(dir.path()).await;
370 assert!(result.is_err());
371 }
372
373 #[tokio::test]
374 async fn rebase_on_base_clean() {
375 let dir = init_temp_repo().await;
376 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
377
378 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
380 tokio::fs::write(dir.path().join("feature.txt"), "feature work").await.unwrap();
381 run_git(dir.path(), &["add", "."]).await.unwrap();
382 run_git(dir.path(), &["commit", "-m", "feature commit"]).await.unwrap();
383
384 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
386 tokio::fs::write(dir.path().join("base.txt"), "base work").await.unwrap();
387 run_git(dir.path(), &["add", "."]).await.unwrap();
388 run_git(dir.path(), &["commit", "-m", "base commit"]).await.unwrap();
389
390 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
392
393 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
396 .await
397 .unwrap();
398
399 let result = rebase_on_base(dir.path(), &branch).await;
400 assert!(result.is_ok());
401
402 assert!(dir.path().join("feature.txt").exists());
404 assert!(dir.path().join("base.txt").exists());
405 }
406
407 #[tokio::test]
408 async fn rebase_on_base_conflict_aborts() {
409 let dir = init_temp_repo().await;
410 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
411
412 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
414 tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
415 run_git(dir.path(), &["add", "."]).await.unwrap();
416 run_git(dir.path(), &["commit", "-m", "feature conflict"]).await.unwrap();
417
418 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
420 tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
421 run_git(dir.path(), &["add", "."]).await.unwrap();
422 run_git(dir.path(), &["commit", "-m", "base conflict"]).await.unwrap();
423
424 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
426 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
427 .await
428 .unwrap();
429
430 let result = rebase_on_base(dir.path(), &branch).await;
431 assert!(result.is_err());
432 assert!(
433 result.unwrap_err().to_string().contains("merge conflicts"),
434 "error should mention merge conflicts"
435 );
436
437 assert!(!dir.path().join(".git/rebase-merge").exists());
439 }
440
441 #[tokio::test]
442 async fn force_push_branch_works() {
443 let dir = init_temp_repo().await;
444
445 let remote_dir = tempfile::tempdir().unwrap();
447 Command::new("git")
448 .args(["clone", "--bare", &dir.path().to_string_lossy(), "."])
449 .current_dir(remote_dir.path())
450 .output()
451 .await
452 .unwrap();
453
454 run_git(dir.path(), &["remote", "add", "origin", &remote_dir.path().to_string_lossy()])
455 .await
456 .unwrap();
457
458 run_git(dir.path(), &["checkout", "-b", "test-branch"]).await.unwrap();
460 tokio::fs::write(dir.path().join("new.txt"), "v1").await.unwrap();
461 run_git(dir.path(), &["add", "."]).await.unwrap();
462 run_git(dir.path(), &["commit", "-m", "v1"]).await.unwrap();
463 push_branch(dir.path(), "test-branch").await.unwrap();
464
465 tokio::fs::write(dir.path().join("new.txt"), "v2").await.unwrap();
467 run_git(dir.path(), &["add", "."]).await.unwrap();
468 run_git(dir.path(), &["commit", "--amend", "-m", "v2"]).await.unwrap();
469
470 assert!(push_branch(dir.path(), "test-branch").await.is_err());
472 assert!(force_push_branch(dir.path(), "test-branch").await.is_ok());
473 }
474
475 #[tokio::test]
476 async fn rebase_with_fallbacks_clean() {
477 let dir = init_temp_repo().await;
478 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
479
480 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
481 tokio::fs::write(dir.path().join("feature.txt"), "feature work").await.unwrap();
482 run_git(dir.path(), &["add", "."]).await.unwrap();
483 run_git(dir.path(), &["commit", "-m", "feature commit"]).await.unwrap();
484
485 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
486 tokio::fs::write(dir.path().join("base.txt"), "base work").await.unwrap();
487 run_git(dir.path(), &["add", "."]).await.unwrap();
488 run_git(dir.path(), &["commit", "-m", "base commit"]).await.unwrap();
489
490 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
491 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
492 .await
493 .unwrap();
494
495 let outcome = rebase_with_fallbacks(dir.path(), &branch).await;
496 assert!(matches!(outcome, RebaseOutcome::Clean));
497 assert!(dir.path().join("feature.txt").exists());
498 assert!(dir.path().join("base.txt").exists());
499 }
500
501 #[tokio::test]
502 async fn rebase_with_fallbacks_merge_fallback() {
503 let dir = init_temp_repo().await;
504 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
505
506 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
508 tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
509 run_git(dir.path(), &["add", "."]).await.unwrap();
510 run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
511
512 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
514 tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
515 run_git(dir.path(), &["add", "."]).await.unwrap();
516 run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
517
518 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
519 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
520 .await
521 .unwrap();
522
523 let outcome = rebase_with_fallbacks(dir.path(), &branch).await;
527 assert!(
528 matches!(outcome, RebaseOutcome::MergeFallback | RebaseOutcome::MergeConflicts(_)),
529 "expected MergeFallback or MergeConflicts, got {outcome:?}"
530 );
531 }
532
533 #[tokio::test]
534 async fn rebase_with_fallbacks_no_remote_fails() {
535 let dir = init_temp_repo().await;
536 let outcome = rebase_with_fallbacks(dir.path(), "main").await;
538 assert!(matches!(outcome, RebaseOutcome::Failed(_)));
539 }
540}