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
146#[derive(Debug)]
148pub enum RebaseOutcome {
149 Clean,
151 RebaseConflicts(Vec<String>),
155 AgentResolved,
157 Failed(String),
159}
160
161pub async fn start_rebase(repo_dir: &Path, base_branch: &str) -> RebaseOutcome {
168 if let Err(e) = run_git(repo_dir, &["fetch", "origin", base_branch]).await {
169 return RebaseOutcome::Failed(format!("failed to fetch {base_branch}: {e}"));
170 }
171
172 let target = format!("origin/{base_branch}");
173
174 let no_editor = [("GIT_EDITOR", "true")];
175 if run_git_with_env(repo_dir, &["rebase", &target], &no_editor).await.is_ok() {
176 return RebaseOutcome::Clean;
177 }
178
179 let conflicting = conflicting_files(repo_dir).await;
181 if conflicting.is_empty() {
182 match skip_empty_rebase_commits(repo_dir).await {
185 Ok(None) => return RebaseOutcome::Clean,
186 Ok(Some(files)) => return RebaseOutcome::RebaseConflicts(files),
187 Err(e) => return RebaseOutcome::Failed(format!("{e:#}")),
188 }
189 }
190 RebaseOutcome::RebaseConflicts(conflicting)
191}
192
193pub async fn conflicting_files(repo_dir: &Path) -> Vec<String> {
195 run_git(repo_dir, &["diff", "--name-only", "--diff-filter=U"])
196 .await
197 .map_or_else(|_| vec![], |output| output.lines().map(String::from).collect())
198}
199
200pub async fn abort_rebase(repo_dir: &Path) {
202 let _ = run_git(repo_dir, &["rebase", "--abort"]).await;
203}
204
205async fn skip_empty_rebase_commits(repo_dir: &Path) -> Result<Option<Vec<String>>> {
216 const MAX_SKIPS: u32 = 10;
217 let no_editor = [("GIT_EDITOR", "true")];
218
219 for _ in 0..MAX_SKIPS {
220 if run_git_with_env(repo_dir, &["rebase", "--skip"], &no_editor).await.is_ok() {
221 return Ok(None);
222 }
223
224 let conflicts = conflicting_files(repo_dir).await;
226 if !conflicts.is_empty() {
227 return Ok(Some(conflicts));
228 }
229 }
230
231 abort_rebase(repo_dir).await;
232 anyhow::bail!("rebase had too many empty commits (skipped {MAX_SKIPS} times)")
233}
234
235pub async fn rebase_continue(
241 repo_dir: &Path,
242 conflicting: &[String],
243) -> Result<Option<Vec<String>>> {
244 for file in conflicting {
245 run_git(repo_dir, &["add", "--", file]).await.with_context(|| format!("staging {file}"))?;
246 }
247
248 let no_editor = [("GIT_EDITOR", "true")];
249 if run_git_with_env(repo_dir, &["rebase", "--continue"], &no_editor).await.is_ok() {
250 return Ok(None);
251 }
252
253 let new_conflicts = conflicting_files(repo_dir).await;
255 if new_conflicts.is_empty() {
256 return skip_empty_rebase_commits(repo_dir).await;
258 }
259 Ok(Some(new_conflicts))
260}
261
262pub async fn default_branch(repo_dir: &Path) -> Result<String> {
264 if let Ok(output) = run_git(repo_dir, &["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
266 if let Some(branch) = output.strip_prefix("refs/remotes/origin/") {
267 return Ok(branch.to_string());
268 }
269 }
270
271 if run_git(repo_dir, &["rev-parse", "--verify", "main"]).await.is_ok() {
273 return Ok("main".to_string());
274 }
275 if run_git(repo_dir, &["rev-parse", "--verify", "master"]).await.is_ok() {
276 return Ok("master".to_string());
277 }
278
279 let output = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
281 .await
282 .context("detecting default branch")?;
283 Ok(output)
284}
285
286pub async fn head_sha(repo_dir: &Path) -> Result<String> {
288 run_git(repo_dir, &["rev-parse", "HEAD"]).await.context("getting HEAD sha")
289}
290
291pub async fn commit_count_since(repo_dir: &Path, since_ref: &str) -> Result<u32> {
293 let output = run_git(repo_dir, &["rev-list", "--count", &format!("{since_ref}..HEAD")])
294 .await
295 .context("counting commits since ref")?;
296 output.parse::<u32>().context("parsing commit count")
297}
298
299pub async fn changed_files_since(repo_dir: &Path, since_ref: &str) -> Result<Vec<String>> {
301 let output = run_git(repo_dir, &["diff", "--name-only", since_ref, "HEAD"])
302 .await
303 .context("listing changed files since ref")?;
304 Ok(output.lines().filter(|l| !l.is_empty()).map(String::from).collect())
305}
306
307async fn run_git(repo_dir: &Path, args: &[&str]) -> Result<String> {
308 run_git_with_env(repo_dir, args, &[]).await
309}
310
311async fn run_git_with_env(repo_dir: &Path, args: &[&str], env: &[(&str, &str)]) -> Result<String> {
312 let mut cmd = Command::new("git");
313 cmd.args(args).current_dir(repo_dir).kill_on_drop(true);
314 for (k, v) in env {
315 cmd.env(k, v);
316 }
317 let output = cmd.output().await.context("spawning git")?;
318
319 if !output.status.success() {
320 let stderr = String::from_utf8_lossy(&output.stderr);
321 anyhow::bail!("git {} failed: {}", args.join(" "), stderr.trim());
322 }
323
324 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 async fn init_temp_repo() -> tempfile::TempDir {
332 let dir = tempfile::tempdir().unwrap();
333
334 Command::new("git").args(["init"]).current_dir(dir.path()).output().await.unwrap();
336
337 Command::new("git")
338 .args(["config", "user.email", "test@test.com"])
339 .current_dir(dir.path())
340 .output()
341 .await
342 .unwrap();
343
344 Command::new("git")
345 .args(["config", "user.name", "Test"])
346 .current_dir(dir.path())
347 .output()
348 .await
349 .unwrap();
350
351 tokio::fs::write(dir.path().join("README.md"), "hello").await.unwrap();
352
353 Command::new("git").args(["add", "."]).current_dir(dir.path()).output().await.unwrap();
354
355 Command::new("git")
356 .args(["commit", "-m", "initial"])
357 .current_dir(dir.path())
358 .output()
359 .await
360 .unwrap();
361
362 dir
363 }
364
365 #[tokio::test]
366 async fn create_and_remove_worktree() {
367 let dir = init_temp_repo().await;
368
369 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
371
372 let wt = create_worktree(dir.path(), 42, &branch).await.unwrap();
373 assert!(wt.path.exists());
374 assert!(wt.branch.starts_with("oven/issue-42-"));
375 assert_eq!(wt.issue_number, 42);
376
377 remove_worktree(dir.path(), &wt.path).await.unwrap();
378 assert!(!wt.path.exists());
379 }
380
381 #[tokio::test]
382 async fn list_worktrees_includes_created() {
383 let dir = init_temp_repo().await;
384 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
385
386 let _wt = create_worktree(dir.path(), 99, &branch).await.unwrap();
387
388 let worktrees = list_worktrees(dir.path()).await.unwrap();
389 assert!(worktrees.len() >= 2);
391 assert!(
392 worktrees
393 .iter()
394 .any(|w| { w.branch.as_deref().is_some_and(|b| b.starts_with("oven/issue-99-")) })
395 );
396 }
397
398 #[tokio::test]
399 async fn branch_naming_convention() {
400 let name = branch_name(123);
401 assert!(name.starts_with("oven/issue-123-"));
402 assert_eq!(name.len(), "oven/issue-123-".len() + 8);
403 let hex_part = &name["oven/issue-123-".len()..];
405 assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
406 }
407
408 #[tokio::test]
409 async fn default_branch_detection() {
410 let dir = init_temp_repo().await;
411 let branch = default_branch(dir.path()).await.unwrap();
412 assert!(branch == "main" || branch == "master", "got: {branch}");
414 }
415
416 #[tokio::test]
417 async fn error_on_non_git_dir() {
418 let dir = tempfile::tempdir().unwrap();
419 let result = list_worktrees(dir.path()).await;
420 assert!(result.is_err());
421 }
422
423 #[tokio::test]
424 async fn force_push_branch_works() {
425 let dir = init_temp_repo().await;
426
427 let remote_dir = tempfile::tempdir().unwrap();
429 Command::new("git")
430 .args(["clone", "--bare", &dir.path().to_string_lossy(), "."])
431 .current_dir(remote_dir.path())
432 .output()
433 .await
434 .unwrap();
435
436 run_git(dir.path(), &["remote", "add", "origin", &remote_dir.path().to_string_lossy()])
437 .await
438 .unwrap();
439
440 run_git(dir.path(), &["checkout", "-b", "test-branch"]).await.unwrap();
442 tokio::fs::write(dir.path().join("new.txt"), "v1").await.unwrap();
443 run_git(dir.path(), &["add", "."]).await.unwrap();
444 run_git(dir.path(), &["commit", "-m", "v1"]).await.unwrap();
445 push_branch(dir.path(), "test-branch").await.unwrap();
446
447 tokio::fs::write(dir.path().join("new.txt"), "v2").await.unwrap();
449 run_git(dir.path(), &["add", "."]).await.unwrap();
450 run_git(dir.path(), &["commit", "--amend", "-m", "v2"]).await.unwrap();
451
452 assert!(push_branch(dir.path(), "test-branch").await.is_err());
454 assert!(force_push_branch(dir.path(), "test-branch").await.is_ok());
455 }
456
457 #[tokio::test]
458 async fn start_rebase_clean() {
459 let dir = init_temp_repo().await;
460 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
461
462 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
463 tokio::fs::write(dir.path().join("feature.txt"), "feature work").await.unwrap();
464 run_git(dir.path(), &["add", "."]).await.unwrap();
465 run_git(dir.path(), &["commit", "-m", "feature commit"]).await.unwrap();
466
467 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
468 tokio::fs::write(dir.path().join("base.txt"), "base work").await.unwrap();
469 run_git(dir.path(), &["add", "."]).await.unwrap();
470 run_git(dir.path(), &["commit", "-m", "base commit"]).await.unwrap();
471
472 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
473 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
474 .await
475 .unwrap();
476
477 let outcome = start_rebase(dir.path(), &branch).await;
478 assert!(matches!(outcome, RebaseOutcome::Clean));
479 assert!(dir.path().join("feature.txt").exists());
480 assert!(dir.path().join("base.txt").exists());
481 }
482
483 #[tokio::test]
484 async fn start_rebase_conflicts() {
485 let dir = init_temp_repo().await;
486 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
487
488 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
490 tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
491 run_git(dir.path(), &["add", "."]).await.unwrap();
492 run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
493
494 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
496 tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
497 run_git(dir.path(), &["add", "."]).await.unwrap();
498 run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
499
500 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
501 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
502 .await
503 .unwrap();
504
505 let outcome = start_rebase(dir.path(), &branch).await;
506 assert!(
507 matches!(outcome, RebaseOutcome::RebaseConflicts(_)),
508 "expected RebaseConflicts, got {outcome:?}"
509 );
510
511 assert!(
513 dir.path().join(".git/rebase-merge").exists()
514 || dir.path().join(".git/rebase-apply").exists()
515 );
516
517 abort_rebase(dir.path()).await;
519 }
520
521 #[tokio::test]
522 async fn start_rebase_no_remote_fails() {
523 let dir = init_temp_repo().await;
524 let outcome = start_rebase(dir.path(), "main").await;
526 assert!(matches!(outcome, RebaseOutcome::Failed(_)));
527 }
528
529 #[tokio::test]
530 async fn rebase_continue_resolves_conflict() {
531 let dir = init_temp_repo().await;
532 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
533
534 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
535 tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
536 run_git(dir.path(), &["add", "."]).await.unwrap();
537 run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
538
539 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
540 tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
541 run_git(dir.path(), &["add", "."]).await.unwrap();
542 run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
543
544 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
545 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
546 .await
547 .unwrap();
548
549 let outcome = start_rebase(dir.path(), &branch).await;
550 let files = match outcome {
551 RebaseOutcome::RebaseConflicts(f) => f,
552 other => panic!("expected RebaseConflicts, got {other:?}"),
553 };
554
555 tokio::fs::write(dir.path().join("README.md"), "resolved version").await.unwrap();
557
558 let result = rebase_continue(dir.path(), &files).await.unwrap();
559 assert!(result.is_none(), "expected rebase to complete, got more conflicts");
560
561 assert!(!dir.path().join(".git/rebase-merge").exists());
563 assert!(!dir.path().join(".git/rebase-apply").exists());
564 }
565
566 #[tokio::test]
567 async fn start_rebase_skips_empty_commit() {
568 let dir = init_temp_repo().await;
569 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
570
571 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
573 tokio::fs::write(dir.path().join("README.md"), "changed").await.unwrap();
574 run_git(dir.path(), &["add", "."]).await.unwrap();
575 run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
576
577 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
579 tokio::fs::write(dir.path().join("README.md"), "changed").await.unwrap();
580 run_git(dir.path(), &["add", "."]).await.unwrap();
581 run_git(dir.path(), &["commit", "-m", "same change on base"]).await.unwrap();
582
583 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
584 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
585 .await
586 .unwrap();
587
588 let outcome = start_rebase(dir.path(), &branch).await;
590 assert!(
591 matches!(outcome, RebaseOutcome::Clean),
592 "expected Clean after skipping empty commit, got {outcome:?}"
593 );
594
595 assert!(!dir.path().join(".git/rebase-merge").exists());
597 assert!(!dir.path().join(".git/rebase-apply").exists());
598 }
599
600 #[tokio::test]
601 async fn rebase_continue_skips_empty_commit_after_real_conflict() {
602 let dir = init_temp_repo().await;
603 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
604
605 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
607 tokio::fs::write(dir.path().join("README.md"), "feature readme").await.unwrap();
608 run_git(dir.path(), &["add", "."]).await.unwrap();
609 run_git(dir.path(), &["commit", "-m", "feature readme"]).await.unwrap();
610
611 tokio::fs::write(dir.path().join("other.txt"), "feature other").await.unwrap();
612 run_git(dir.path(), &["add", "."]).await.unwrap();
613 run_git(dir.path(), &["commit", "-m", "feature other"]).await.unwrap();
614
615 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
617 tokio::fs::write(dir.path().join("README.md"), "base readme").await.unwrap();
618 run_git(dir.path(), &["add", "."]).await.unwrap();
619 run_git(dir.path(), &["commit", "-m", "base readme"]).await.unwrap();
620
621 tokio::fs::write(dir.path().join("other.txt"), "feature other").await.unwrap();
622 run_git(dir.path(), &["add", "."]).await.unwrap();
623 run_git(dir.path(), &["commit", "-m", "same other on base"]).await.unwrap();
624
625 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
626 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
627 .await
628 .unwrap();
629
630 let outcome = start_rebase(dir.path(), &branch).await;
632 let files = match outcome {
633 RebaseOutcome::RebaseConflicts(f) => f,
634 other => panic!("expected RebaseConflicts, got {other:?}"),
635 };
636 assert!(files.contains(&"README.md".to_string()));
637
638 tokio::fs::write(dir.path().join("README.md"), "resolved").await.unwrap();
640
641 let result = rebase_continue(dir.path(), &files).await.unwrap();
643 assert!(result.is_none(), "expected rebase to complete, got more conflicts");
644
645 assert!(!dir.path().join(".git/rebase-merge").exists());
646 assert!(!dir.path().join(".git/rebase-apply").exists());
647 }
648}