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(
32 repo_dir: &Path,
33 issue_number: u32,
34 base_branch: &str,
35) -> Result<Worktree> {
36 let branch = branch_name(issue_number);
37 let worktree_path =
38 repo_dir.join(".oven").join("worktrees").join(format!("issue-{issue_number}"));
39
40 if let Some(parent) = worktree_path.parent() {
42 tokio::fs::create_dir_all(parent).await.context("creating worktree parent directory")?;
43 }
44
45 let start_point = format!("origin/{base_branch}");
46 run_git(
47 repo_dir,
48 &["worktree", "add", "-b", &branch, &worktree_path.to_string_lossy(), &start_point],
49 )
50 .await
51 .context("creating worktree")?;
52
53 Ok(Worktree { path: worktree_path, branch, issue_number })
54}
55
56pub async fn remove_worktree(repo_dir: &Path, worktree_path: &Path) -> Result<()> {
58 run_git(repo_dir, &["worktree", "remove", "--force", &worktree_path.to_string_lossy()])
59 .await
60 .context("removing worktree")?;
61 Ok(())
62}
63
64pub async fn list_worktrees(repo_dir: &Path) -> Result<Vec<WorktreeInfo>> {
66 let output = run_git(repo_dir, &["worktree", "list", "--porcelain"])
67 .await
68 .context("listing worktrees")?;
69
70 let mut worktrees = Vec::new();
71 let mut current_path: Option<PathBuf> = None;
72 let mut current_branch: Option<String> = None;
73
74 for line in output.lines() {
75 if let Some(path_str) = line.strip_prefix("worktree ") {
76 if let Some(path) = current_path.take() {
78 worktrees.push(WorktreeInfo { path, branch: current_branch.take() });
79 }
80 current_path = Some(PathBuf::from(path_str));
81 } else if let Some(branch_ref) = line.strip_prefix("branch ") {
82 current_branch =
84 Some(branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref).to_string());
85 }
86 }
87
88 if let Some(path) = current_path {
90 worktrees.push(WorktreeInfo { path, branch: current_branch });
91 }
92
93 Ok(worktrees)
94}
95
96pub async fn clean_worktrees(repo_dir: &Path) -> Result<u32> {
98 let before = list_worktrees(repo_dir).await?;
99 run_git(repo_dir, &["worktree", "prune"]).await.context("pruning worktrees")?;
100 let after = list_worktrees(repo_dir).await?;
101
102 let pruned = if before.len() > after.len() { before.len() - after.len() } else { 0 };
103 Ok(u32::try_from(pruned).unwrap_or(u32::MAX))
104}
105
106pub async fn delete_branch(repo_dir: &Path, branch: &str) -> Result<()> {
108 run_git(repo_dir, &["branch", "-D", branch]).await.context("deleting branch")?;
109 Ok(())
110}
111
112pub async fn list_merged_branches(repo_dir: &Path, base: &str) -> Result<Vec<String>> {
114 let output = run_git(repo_dir, &["branch", "--merged", base])
115 .await
116 .context("listing merged branches")?;
117
118 let branches = output
119 .lines()
120 .map(|l| l.trim().trim_start_matches("* ").to_string())
121 .filter(|b| b.starts_with("oven/"))
122 .collect();
123
124 Ok(branches)
125}
126
127pub async fn empty_commit(repo_dir: &Path, message: &str) -> Result<()> {
129 run_git(repo_dir, &["commit", "--allow-empty", "-m", message])
130 .await
131 .context("creating empty commit")?;
132 Ok(())
133}
134
135pub async fn is_dirty(repo_dir: &Path) -> Result<bool> {
137 let output =
138 run_git(repo_dir, &["status", "--porcelain"]).await.context("checking dirty state")?;
139 Ok(!output.is_empty())
140}
141
142pub async fn commit_all(repo_dir: &Path, message: &str) -> Result<bool> {
154 let tracked_changes = run_git(repo_dir, &["status", "--porcelain", "-uno"])
155 .await
156 .context("checking tracked changes")?;
157 if tracked_changes.is_empty() {
158 return Ok(false);
159 }
160 run_git(repo_dir, &["add", "-u"]).await.context("staging tracked changes")?;
161 run_git(repo_dir, &["commit", "-m", message]).await.context("committing changes")?;
162 Ok(true)
163}
164
165pub async fn push_branch(repo_dir: &Path, branch: &str) -> Result<()> {
167 run_git(repo_dir, &["push", "origin", branch]).await.context("pushing branch")?;
168 Ok(())
169}
170
171pub async fn fetch_branch(repo_dir: &Path, branch: &str) -> Result<()> {
177 fetch_with_retry(repo_dir, branch)
178 .await
179 .with_context(|| format!("fetching {branch} from origin"))
180}
181
182pub async fn advance_local_branch(repo_dir: &Path, branch: &str) -> Result<()> {
190 let remote_ref = format!("origin/{branch}");
191 let current = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
192 .await
193 .context("detecting current branch")?;
194
195 if current == branch {
196 run_git(repo_dir, &["merge", "--ff-only", &remote_ref])
197 .await
198 .context("fast-forwarding checked-out branch")?;
199 } else {
200 if run_git(repo_dir, &["merge-base", "--is-ancestor", branch, &remote_ref]).await.is_ok() {
202 run_git(repo_dir, &["branch", "-f", branch, &remote_ref])
203 .await
204 .context("updating local branch ref")?;
205 }
206 }
207 Ok(())
208}
209
210pub async fn force_push_branch(repo_dir: &Path, branch: &str) -> Result<()> {
214 let lease = format!("--force-with-lease=refs/heads/{branch}");
215 run_git(repo_dir, &["push", &lease, "origin", branch]).await.context("force-pushing branch")?;
216 Ok(())
217}
218
219#[derive(Debug)]
221pub enum RebaseOutcome {
222 Clean,
224 RebaseConflicts(Vec<String>),
228 AgentResolved,
230 Failed(String),
232}
233
234pub async fn start_rebase(repo_dir: &Path, base_branch: &str) -> RebaseOutcome {
245 if let Err(e) = fetch_with_retry(repo_dir, base_branch).await {
246 return RebaseOutcome::Failed(format!("failed to fetch {base_branch}: {e}"));
247 }
248
249 let target = format!("origin/{base_branch}");
250
251 let no_editor = [("GIT_EDITOR", "true")];
252 let Err(rebase_err) =
253 run_git_with_env(repo_dir, &["rebase", "--empty=drop", &target], &no_editor).await
254 else {
255 return RebaseOutcome::Clean;
256 };
257
258 if !rebase_in_progress(repo_dir).await {
262 return RebaseOutcome::Failed(format!("rebase could not start: {rebase_err}"));
263 }
264
265 let conflicting = conflicting_files(repo_dir).await;
266 if conflicting.is_empty() {
267 abort_rebase(repo_dir).await;
271 return RebaseOutcome::Failed(
272 "rebase stopped with no conflicts and no empty commits".to_string(),
273 );
274 }
275 RebaseOutcome::RebaseConflicts(conflicting)
276}
277
278pub async fn conflicting_files(repo_dir: &Path) -> Vec<String> {
284 run_git(repo_dir, &["diff", "--name-only", "--diff-filter=U"])
285 .await
286 .map_or_else(|_| vec![], |output| output.lines().map(String::from).collect())
287}
288
289pub async fn files_with_conflict_markers(repo_dir: &Path, files: &[String]) -> Vec<String> {
295 let mut unresolved = Vec::new();
296 for file in files {
297 let path = repo_dir.join(file);
298 if let Ok(content) = tokio::fs::read_to_string(&path).await {
299 if content.contains("<<<<<<<") || content.contains(">>>>>>>") {
300 unresolved.push(file.clone());
301 }
302 }
303 }
304 unresolved
305}
306
307pub async fn abort_rebase(repo_dir: &Path) {
309 let _ = run_git(repo_dir, &["rebase", "--abort"]).await;
310}
311
312pub async fn rebase_in_progress(repo_dir: &Path) -> bool {
319 let git_dir = run_git(repo_dir, &["rev-parse", "--git-dir"])
320 .await
321 .map_or_else(|_| repo_dir.join(".git"), |s| PathBuf::from(s.trim()));
322
323 let git_dir = if git_dir.is_absolute() { git_dir } else { repo_dir.join(git_dir) };
324
325 tokio::fs::try_exists(git_dir.join("rebase-merge")).await.unwrap_or(false)
326 || tokio::fs::try_exists(git_dir.join("rebase-apply")).await.unwrap_or(false)
327}
328
329async fn fetch_with_retry(repo_dir: &Path, branch: &str) -> Result<()> {
331 match run_git(repo_dir, &["fetch", "origin", branch]).await {
332 Ok(_) => Ok(()),
333 Err(first_err) => {
334 let msg = format!("{first_err:#}");
335 if msg.contains("unable to update local ref") || msg.contains("cannot lock ref") {
336 tracing::warn!(branch, "fetch failed with ref lock contention, retrying");
337 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
338 run_git(repo_dir, &["fetch", "origin", branch])
339 .await
340 .map(|_| ())
341 .with_context(|| format!("retry fetch {branch}"))
342 } else {
343 Err(first_err)
344 }
345 }
346 }
347}
348
349pub async fn rebase_continue(
359 repo_dir: &Path,
360 conflicting: &[String],
361) -> Result<Option<Vec<String>>> {
362 for file in conflicting {
363 run_git(repo_dir, &["add", "--", file]).await.with_context(|| format!("staging {file}"))?;
364 }
365
366 let no_editor = [("GIT_EDITOR", "true")];
367 let Err(continue_err) = run_git_with_env(repo_dir, &["rebase", "--continue"], &no_editor).await
368 else {
369 return Ok(None);
370 };
371
372 if !rebase_in_progress(repo_dir).await {
374 anyhow::bail!("rebase --continue failed and no rebase is in progress: {continue_err}");
375 }
376
377 let new_conflicts = conflicting_files(repo_dir).await;
378 if new_conflicts.is_empty() {
379 anyhow::bail!("rebase stopped after continue with no conflicts: {continue_err}");
380 }
381 Ok(Some(new_conflicts))
382}
383
384pub async fn default_branch(repo_dir: &Path) -> Result<String> {
386 if let Ok(output) = run_git(repo_dir, &["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
388 if let Some(branch) = output.strip_prefix("refs/remotes/origin/") {
389 return Ok(branch.to_string());
390 }
391 }
392
393 if run_git(repo_dir, &["rev-parse", "--verify", "main"]).await.is_ok() {
395 return Ok("main".to_string());
396 }
397 if run_git(repo_dir, &["rev-parse", "--verify", "master"]).await.is_ok() {
398 return Ok("master".to_string());
399 }
400
401 let output = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
403 .await
404 .context("detecting default branch")?;
405 Ok(output)
406}
407
408pub async fn head_sha(repo_dir: &Path) -> Result<String> {
410 run_git(repo_dir, &["rev-parse", "HEAD"]).await.context("getting HEAD sha")
411}
412
413pub async fn commit_count_since(repo_dir: &Path, since_ref: &str) -> Result<u32> {
415 let output = run_git(repo_dir, &["rev-list", "--count", &format!("{since_ref}..HEAD")])
416 .await
417 .context("counting commits since ref")?;
418 output.parse::<u32>().context("parsing commit count")
419}
420
421pub async fn changed_files_since(repo_dir: &Path, since_ref: &str) -> Result<Vec<String>> {
423 let output = run_git(repo_dir, &["diff", "--name-only", since_ref, "HEAD"])
424 .await
425 .context("listing changed files since ref")?;
426 Ok(output.lines().filter(|l| !l.is_empty()).map(String::from).collect())
427}
428
429async fn run_git(repo_dir: &Path, args: &[&str]) -> Result<String> {
430 run_git_with_env(repo_dir, args, &[]).await
431}
432
433async fn run_git_with_env(repo_dir: &Path, args: &[&str], env: &[(&str, &str)]) -> Result<String> {
434 let mut cmd = Command::new("git");
435 cmd.args(args).current_dir(repo_dir).kill_on_drop(true);
436 for (k, v) in env {
437 cmd.env(k, v);
438 }
439 let output = cmd.output().await.context("spawning git")?;
440
441 if !output.status.success() {
442 let stderr = String::from_utf8_lossy(&output.stderr);
443 anyhow::bail!("git {} failed: {}", args.join(" "), stderr.trim());
444 }
445
446 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 async fn init_temp_repo() -> tempfile::TempDir {
454 let dir = tempfile::tempdir().unwrap();
455
456 Command::new("git").args(["init"]).current_dir(dir.path()).output().await.unwrap();
458
459 Command::new("git")
460 .args(["config", "user.email", "test@test.com"])
461 .current_dir(dir.path())
462 .output()
463 .await
464 .unwrap();
465
466 Command::new("git")
467 .args(["config", "user.name", "Test"])
468 .current_dir(dir.path())
469 .output()
470 .await
471 .unwrap();
472
473 tokio::fs::write(dir.path().join("README.md"), "hello").await.unwrap();
474
475 Command::new("git").args(["add", "."]).current_dir(dir.path()).output().await.unwrap();
476
477 Command::new("git")
478 .args(["commit", "-m", "initial"])
479 .current_dir(dir.path())
480 .output()
481 .await
482 .unwrap();
483
484 dir
485 }
486
487 async fn init_temp_repo_with_remote() -> (tempfile::TempDir, tempfile::TempDir) {
490 let dir = init_temp_repo().await;
491
492 let remote_dir = tempfile::tempdir().unwrap();
493 Command::new("git")
494 .args(["clone", "--bare", &dir.path().to_string_lossy(), "."])
495 .current_dir(remote_dir.path())
496 .output()
497 .await
498 .unwrap();
499 run_git(dir.path(), &["remote", "add", "origin", &remote_dir.path().to_string_lossy()])
500 .await
501 .unwrap();
502 run_git(dir.path(), &["fetch", "origin"]).await.unwrap();
503
504 (dir, remote_dir)
505 }
506
507 #[tokio::test]
508 async fn create_and_remove_worktree() {
509 let (dir, _remote) = init_temp_repo_with_remote().await;
510
511 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
513
514 let wt = create_worktree(dir.path(), 42, &branch).await.unwrap();
515 assert!(wt.path.exists());
516 assert!(wt.branch.starts_with("oven/issue-42-"));
517 assert_eq!(wt.issue_number, 42);
518
519 remove_worktree(dir.path(), &wt.path).await.unwrap();
520 assert!(!wt.path.exists());
521 }
522
523 #[tokio::test]
524 async fn list_worktrees_includes_created() {
525 let (dir, _remote) = init_temp_repo_with_remote().await;
526 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
527
528 let _wt = create_worktree(dir.path(), 99, &branch).await.unwrap();
529
530 let worktrees = list_worktrees(dir.path()).await.unwrap();
531 assert!(worktrees.len() >= 2);
533 assert!(
534 worktrees
535 .iter()
536 .any(|w| { w.branch.as_deref().is_some_and(|b| b.starts_with("oven/issue-99-")) })
537 );
538 }
539
540 #[tokio::test]
541 async fn branch_naming_convention() {
542 let name = branch_name(123);
543 assert!(name.starts_with("oven/issue-123-"));
544 assert_eq!(name.len(), "oven/issue-123-".len() + 8);
545 let hex_part = &name["oven/issue-123-".len()..];
547 assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
548 }
549
550 #[tokio::test]
551 async fn default_branch_detection() {
552 let dir = init_temp_repo().await;
553 let branch = default_branch(dir.path()).await.unwrap();
554 assert!(branch == "main" || branch == "master", "got: {branch}");
556 }
557
558 #[tokio::test]
559 async fn error_on_non_git_dir() {
560 let dir = tempfile::tempdir().unwrap();
561 let result = list_worktrees(dir.path()).await;
562 assert!(result.is_err());
563 }
564
565 #[tokio::test]
566 async fn force_push_branch_works() {
567 let dir = init_temp_repo().await;
568
569 let remote_dir = tempfile::tempdir().unwrap();
571 Command::new("git")
572 .args(["clone", "--bare", &dir.path().to_string_lossy(), "."])
573 .current_dir(remote_dir.path())
574 .output()
575 .await
576 .unwrap();
577
578 run_git(dir.path(), &["remote", "add", "origin", &remote_dir.path().to_string_lossy()])
579 .await
580 .unwrap();
581
582 run_git(dir.path(), &["checkout", "-b", "test-branch"]).await.unwrap();
584 tokio::fs::write(dir.path().join("new.txt"), "v1").await.unwrap();
585 run_git(dir.path(), &["add", "."]).await.unwrap();
586 run_git(dir.path(), &["commit", "-m", "v1"]).await.unwrap();
587 push_branch(dir.path(), "test-branch").await.unwrap();
588
589 tokio::fs::write(dir.path().join("new.txt"), "v2").await.unwrap();
591 run_git(dir.path(), &["add", "."]).await.unwrap();
592 run_git(dir.path(), &["commit", "--amend", "-m", "v2"]).await.unwrap();
593
594 assert!(push_branch(dir.path(), "test-branch").await.is_err());
596 assert!(force_push_branch(dir.path(), "test-branch").await.is_ok());
597 }
598
599 #[tokio::test]
600 async fn start_rebase_clean() {
601 let dir = init_temp_repo().await;
602 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
603
604 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
605 tokio::fs::write(dir.path().join("feature.txt"), "feature work").await.unwrap();
606 run_git(dir.path(), &["add", "."]).await.unwrap();
607 run_git(dir.path(), &["commit", "-m", "feature commit"]).await.unwrap();
608
609 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
610 tokio::fs::write(dir.path().join("base.txt"), "base work").await.unwrap();
611 run_git(dir.path(), &["add", "."]).await.unwrap();
612 run_git(dir.path(), &["commit", "-m", "base commit"]).await.unwrap();
613
614 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
615 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
616 .await
617 .unwrap();
618
619 let outcome = start_rebase(dir.path(), &branch).await;
620 assert!(matches!(outcome, RebaseOutcome::Clean));
621 assert!(dir.path().join("feature.txt").exists());
622 assert!(dir.path().join("base.txt").exists());
623 }
624
625 #[tokio::test]
626 async fn start_rebase_conflicts() {
627 let dir = init_temp_repo().await;
628 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
629
630 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
632 tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
633 run_git(dir.path(), &["add", "."]).await.unwrap();
634 run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
635
636 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
638 tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
639 run_git(dir.path(), &["add", "."]).await.unwrap();
640 run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
641
642 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
643 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
644 .await
645 .unwrap();
646
647 let outcome = start_rebase(dir.path(), &branch).await;
648 assert!(
649 matches!(outcome, RebaseOutcome::RebaseConflicts(_)),
650 "expected RebaseConflicts, got {outcome:?}"
651 );
652
653 assert!(
655 dir.path().join(".git/rebase-merge").exists()
656 || dir.path().join(".git/rebase-apply").exists()
657 );
658
659 abort_rebase(dir.path()).await;
661 }
662
663 #[tokio::test]
664 async fn start_rebase_no_remote_fails() {
665 let dir = init_temp_repo().await;
666 let outcome = start_rebase(dir.path(), "main").await;
668 assert!(matches!(outcome, RebaseOutcome::Failed(_)));
669 }
670
671 #[tokio::test]
672 async fn rebase_continue_resolves_conflict() {
673 let dir = init_temp_repo().await;
674 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
675
676 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
677 tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
678 run_git(dir.path(), &["add", "."]).await.unwrap();
679 run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
680
681 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
682 tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
683 run_git(dir.path(), &["add", "."]).await.unwrap();
684 run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
685
686 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
687 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
688 .await
689 .unwrap();
690
691 let outcome = start_rebase(dir.path(), &branch).await;
692 let files = match outcome {
693 RebaseOutcome::RebaseConflicts(f) => f,
694 other => panic!("expected RebaseConflicts, got {other:?}"),
695 };
696
697 tokio::fs::write(dir.path().join("README.md"), "resolved version").await.unwrap();
699
700 let result = rebase_continue(dir.path(), &files).await.unwrap();
701 assert!(result.is_none(), "expected rebase to complete, got more conflicts");
702
703 assert!(!dir.path().join(".git/rebase-merge").exists());
705 assert!(!dir.path().join(".git/rebase-apply").exists());
706 }
707
708 #[tokio::test]
709 async fn start_rebase_skips_empty_commit() {
710 let dir = init_temp_repo().await;
711 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
712
713 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
715 tokio::fs::write(dir.path().join("README.md"), "changed").await.unwrap();
716 run_git(dir.path(), &["add", "."]).await.unwrap();
717 run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
718
719 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
721 tokio::fs::write(dir.path().join("README.md"), "changed").await.unwrap();
722 run_git(dir.path(), &["add", "."]).await.unwrap();
723 run_git(dir.path(), &["commit", "-m", "same change on base"]).await.unwrap();
724
725 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
726 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
727 .await
728 .unwrap();
729
730 let outcome = start_rebase(dir.path(), &branch).await;
732 assert!(
733 matches!(outcome, RebaseOutcome::Clean),
734 "expected Clean after skipping empty commit, got {outcome:?}"
735 );
736
737 assert!(!dir.path().join(".git/rebase-merge").exists());
739 assert!(!dir.path().join(".git/rebase-apply").exists());
740 }
741
742 #[tokio::test]
743 async fn rebase_continue_skips_empty_commit_after_real_conflict() {
744 let dir = init_temp_repo().await;
745 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
746
747 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
749 tokio::fs::write(dir.path().join("README.md"), "feature readme").await.unwrap();
750 run_git(dir.path(), &["add", "."]).await.unwrap();
751 run_git(dir.path(), &["commit", "-m", "feature readme"]).await.unwrap();
752
753 tokio::fs::write(dir.path().join("other.txt"), "feature other").await.unwrap();
754 run_git(dir.path(), &["add", "."]).await.unwrap();
755 run_git(dir.path(), &["commit", "-m", "feature other"]).await.unwrap();
756
757 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
759 tokio::fs::write(dir.path().join("README.md"), "base readme").await.unwrap();
760 run_git(dir.path(), &["add", "."]).await.unwrap();
761 run_git(dir.path(), &["commit", "-m", "base readme"]).await.unwrap();
762
763 tokio::fs::write(dir.path().join("other.txt"), "feature other").await.unwrap();
764 run_git(dir.path(), &["add", "."]).await.unwrap();
765 run_git(dir.path(), &["commit", "-m", "same other on base"]).await.unwrap();
766
767 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
768 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
769 .await
770 .unwrap();
771
772 let outcome = start_rebase(dir.path(), &branch).await;
774 let files = match outcome {
775 RebaseOutcome::RebaseConflicts(f) => f,
776 other => panic!("expected RebaseConflicts, got {other:?}"),
777 };
778 assert!(files.contains(&"README.md".to_string()));
779
780 tokio::fs::write(dir.path().join("README.md"), "resolved").await.unwrap();
782
783 let result = rebase_continue(dir.path(), &files).await.unwrap();
785 assert!(result.is_none(), "expected rebase to complete, got more conflicts");
786
787 assert!(!dir.path().join(".git/rebase-merge").exists());
788 assert!(!dir.path().join(".git/rebase-apply").exists());
789 }
790
791 #[tokio::test]
792 async fn conflict_markers_detected_in_content() {
793 let dir = tempfile::tempdir().unwrap();
794 let with_markers = "line 1\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nline 2";
795 let without_markers = "line 1\nresolved content\nline 2";
796
797 tokio::fs::write(dir.path().join("conflicted.txt"), with_markers).await.unwrap();
798 tokio::fs::write(dir.path().join("resolved.txt"), without_markers).await.unwrap();
799
800 let files = vec!["conflicted.txt".to_string(), "resolved.txt".to_string()];
801 let result = files_with_conflict_markers(dir.path(), &files).await;
802
803 assert_eq!(result, vec!["conflicted.txt"]);
804 }
805
806 #[tokio::test]
807 async fn conflict_markers_empty_when_all_resolved() {
808 let dir = tempfile::tempdir().unwrap();
809 tokio::fs::write(dir.path().join("a.txt"), "clean content").await.unwrap();
810 tokio::fs::write(dir.path().join("b.txt"), "also clean").await.unwrap();
811
812 let files = vec!["a.txt".to_string(), "b.txt".to_string()];
813 let result = files_with_conflict_markers(dir.path(), &files).await;
814
815 assert!(result.is_empty());
816 }
817
818 #[tokio::test]
819 async fn conflict_markers_missing_file_skipped() {
820 let dir = tempfile::tempdir().unwrap();
821 let files = vec!["nonexistent.txt".to_string()];
822 let result = files_with_conflict_markers(dir.path(), &files).await;
823
824 assert!(result.is_empty());
825 }
826
827 #[tokio::test]
828 async fn resolved_file_still_unmerged_in_index() {
829 let dir = init_temp_repo().await;
833 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
834
835 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
836 tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
837 run_git(dir.path(), &["add", "."]).await.unwrap();
838 run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
839
840 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
841 tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
842 run_git(dir.path(), &["add", "."]).await.unwrap();
843 run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
844
845 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
846 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
847 .await
848 .unwrap();
849
850 let outcome = start_rebase(dir.path(), &branch).await;
851 let files = match outcome {
852 RebaseOutcome::RebaseConflicts(f) => f,
853 other => panic!("expected RebaseConflicts, got {other:?}"),
854 };
855
856 tokio::fs::write(dir.path().join("README.md"), "resolved version").await.unwrap();
858
859 let index_conflicts = conflicting_files(dir.path()).await;
861 assert!(
862 !index_conflicts.is_empty(),
863 "file should still be Unmerged in git index before git add"
864 );
865
866 let content_conflicts = files_with_conflict_markers(dir.path(), &files).await;
868 assert!(
869 content_conflicts.is_empty(),
870 "file content has no conflict markers, should be empty"
871 );
872
873 abort_rebase(dir.path()).await;
875 }
876
877 #[tokio::test]
878 async fn is_dirty_detects_modified_files() {
879 let dir = init_temp_repo().await;
880 assert!(!is_dirty(dir.path()).await.unwrap());
881
882 tokio::fs::write(dir.path().join("README.md"), "modified").await.unwrap();
883 assert!(is_dirty(dir.path()).await.unwrap());
884 }
885
886 #[tokio::test]
887 async fn commit_all_commits_tracked_changes_only() {
888 let dir = init_temp_repo().await;
889
890 tokio::fs::write(dir.path().join("new.txt"), "new file").await.unwrap();
892 tokio::fs::write(dir.path().join("README.md"), "modified").await.unwrap();
894
895 let committed = commit_all(dir.path(), "save agent work").await.unwrap();
896 assert!(committed);
897
898 let log = run_git(dir.path(), &["log", "--oneline", "-1"]).await.unwrap();
899 assert!(log.contains("save agent work"));
900
901 let diff = run_git(dir.path(), &["diff", "HEAD~1", "--name-only"]).await.unwrap();
903 assert!(diff.contains("README.md"));
904 assert!(!diff.contains("new.txt"));
905
906 assert!(is_dirty(dir.path()).await.unwrap());
908 }
909
910 #[tokio::test]
911 async fn commit_all_returns_false_when_clean() {
912 let dir = init_temp_repo().await;
913 let committed = commit_all(dir.path(), "nothing to commit").await.unwrap();
914 assert!(!committed);
915 }
916
917 #[tokio::test]
918 async fn commit_all_skips_untracked_only_worktree() {
919 let dir = init_temp_repo().await;
920
921 tokio::fs::write(dir.path().join("temp.log"), "agent output").await.unwrap();
923 assert!(is_dirty(dir.path()).await.unwrap());
924
925 let committed = commit_all(dir.path(), "should not commit").await.unwrap();
926 assert!(!committed);
927
928 let log = run_git(dir.path(), &["log", "--oneline", "-1"]).await.unwrap();
930 assert!(!log.contains("should not commit"));
931 }
932
933 #[tokio::test]
934 async fn start_rebase_dirty_worktree_returns_failed() {
935 let dir = init_temp_repo().await;
936 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
937
938 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
939 tokio::fs::write(dir.path().join("feature.txt"), "feature work").await.unwrap();
940 run_git(dir.path(), &["add", "."]).await.unwrap();
941 run_git(dir.path(), &["commit", "-m", "feature commit"]).await.unwrap();
942
943 tokio::fs::write(dir.path().join("README.md"), "uncommitted work").await.unwrap();
945
946 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
947 .await
948 .unwrap();
949
950 let outcome = start_rebase(dir.path(), &branch).await;
951 assert!(
952 matches!(outcome, RebaseOutcome::Failed(ref msg) if msg.contains("could not start")),
953 "expected Failed with 'could not start' message, got {outcome:?}"
954 );
955 }
956
957 #[tokio::test]
958 async fn rebase_in_progress_false_when_clean() {
959 let dir = init_temp_repo().await;
960 assert!(!rebase_in_progress(dir.path()).await);
961 }
962
963 #[tokio::test]
964 async fn rebase_in_progress_true_during_conflict() {
965 let dir = init_temp_repo().await;
966 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
967
968 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
969 tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
970 run_git(dir.path(), &["add", "."]).await.unwrap();
971 run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
972
973 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
974 tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
975 run_git(dir.path(), &["add", "."]).await.unwrap();
976 run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
977
978 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
979 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
980 .await
981 .unwrap();
982
983 let outcome = start_rebase(dir.path(), &branch).await;
984 assert!(matches!(outcome, RebaseOutcome::RebaseConflicts(_)));
985 assert!(rebase_in_progress(dir.path()).await);
986
987 abort_rebase(dir.path()).await;
988 assert!(!rebase_in_progress(dir.path()).await);
989 }
990
991 #[tokio::test]
992 async fn fetch_branch_updates_remote_tracking_ref() {
993 let (dir, remote_dir) = init_temp_repo_with_remote().await;
994 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
995
996 let before_sha =
997 run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
998
999 let _other = push_remote_commit(remote_dir.path(), &branch, "remote.txt").await;
1000
1001 let stale_sha =
1003 run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
1004 assert_eq!(before_sha, stale_sha);
1005
1006 fetch_branch(dir.path(), &branch).await.unwrap();
1008
1009 let after_sha =
1010 run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
1011 assert_ne!(before_sha, after_sha, "origin/{branch} should have advanced after fetch");
1012 }
1013
1014 #[tokio::test]
1015 async fn fetch_branch_no_remote_errors() {
1016 let dir = init_temp_repo().await;
1017 let result = fetch_branch(dir.path(), "main").await;
1018 assert!(result.is_err());
1019 }
1020
1021 #[tokio::test]
1022 async fn worktree_after_fetch_includes_remote_changes() {
1023 let (dir, remote_dir) = init_temp_repo_with_remote().await;
1024 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
1025
1026 let _other = push_remote_commit(remote_dir.path(), &branch, "merged.txt").await;
1027
1028 fetch_branch(dir.path(), &branch).await.unwrap();
1029
1030 let wt = create_worktree(dir.path(), 99, &branch).await.unwrap();
1031 assert!(
1032 wt.path.join("merged.txt").exists(),
1033 "worktree should contain the file from the merged PR"
1034 );
1035 }
1036
1037 async fn push_remote_commit(
1039 remote_dir: &std::path::Path,
1040 branch: &str,
1041 filename: &str,
1042 ) -> tempfile::TempDir {
1043 let other = tempfile::tempdir().unwrap();
1044 Command::new("git")
1045 .args(["clone", &remote_dir.to_string_lossy(), "."])
1046 .current_dir(other.path())
1047 .output()
1048 .await
1049 .unwrap();
1050 for args in
1051 [&["config", "user.email", "test@test.com"][..], &["config", "user.name", "Test"]]
1052 {
1053 Command::new("git").args(args).current_dir(other.path()).output().await.unwrap();
1054 }
1055 tokio::fs::write(other.path().join(filename), "content").await.unwrap();
1056 run_git(other.path(), &["add", "."]).await.unwrap();
1057 run_git(other.path(), &["commit", "-m", &format!("add {filename}")]).await.unwrap();
1058 run_git(other.path(), &["push", "origin", branch]).await.unwrap();
1059 other
1060 }
1061
1062 #[tokio::test]
1063 async fn advance_local_branch_when_checked_out() {
1064 let (dir, remote_dir) = init_temp_repo_with_remote().await;
1065 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
1066
1067 let before = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
1068
1069 let _other = push_remote_commit(remote_dir.path(), &branch, "new.txt").await;
1070 fetch_branch(dir.path(), &branch).await.unwrap();
1071
1072 let after_fetch = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
1074 assert_eq!(before, after_fetch, "local branch should not advance from fetch alone");
1075
1076 advance_local_branch(dir.path(), &branch).await.unwrap();
1078
1079 let after_advance = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
1080 let remote_sha =
1081 run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
1082 assert_eq!(after_advance, remote_sha, "local branch should match origin after advance");
1083 assert!(dir.path().join("new.txt").exists(), "working tree should have the new file");
1084 }
1085
1086 #[tokio::test]
1087 async fn advance_local_branch_when_not_checked_out() {
1088 let (dir, remote_dir) = init_temp_repo_with_remote().await;
1089 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
1090
1091 run_git(dir.path(), &["checkout", "-b", "other"]).await.unwrap();
1093
1094 let before = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
1095
1096 let _other = push_remote_commit(remote_dir.path(), &branch, "new.txt").await;
1097 fetch_branch(dir.path(), &branch).await.unwrap();
1098
1099 advance_local_branch(dir.path(), &branch).await.unwrap();
1100
1101 let after = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
1102 let remote_sha =
1103 run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
1104 assert_ne!(before, after, "local branch should have moved");
1105 assert_eq!(after, remote_sha, "local branch should match origin after advance");
1106 }
1107}