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 push_branch(repo_dir: &Path, branch: &str) -> Result<()> {
137 run_git(repo_dir, &["push", "origin", branch]).await.context("pushing branch")?;
138 Ok(())
139}
140
141pub async fn fetch_branch(repo_dir: &Path, branch: &str) -> Result<()> {
146 run_git(repo_dir, &["fetch", "origin", branch])
147 .await
148 .with_context(|| format!("fetching {branch} from origin"))?;
149 Ok(())
150}
151
152pub async fn advance_local_branch(repo_dir: &Path, branch: &str) -> Result<()> {
160 let remote_ref = format!("origin/{branch}");
161 let current = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
162 .await
163 .context("detecting current branch")?;
164
165 if current == branch {
166 run_git(repo_dir, &["merge", "--ff-only", &remote_ref])
167 .await
168 .context("fast-forwarding checked-out branch")?;
169 } else {
170 if run_git(repo_dir, &["merge-base", "--is-ancestor", branch, &remote_ref]).await.is_ok() {
172 run_git(repo_dir, &["branch", "-f", branch, &remote_ref])
173 .await
174 .context("updating local branch ref")?;
175 }
176 }
177 Ok(())
178}
179
180pub async fn force_push_branch(repo_dir: &Path, branch: &str) -> Result<()> {
184 let lease = format!("--force-with-lease=refs/heads/{branch}");
185 run_git(repo_dir, &["push", &lease, "origin", branch]).await.context("force-pushing branch")?;
186 Ok(())
187}
188
189#[derive(Debug)]
191pub enum RebaseOutcome {
192 Clean,
194 RebaseConflicts(Vec<String>),
198 AgentResolved,
200 Failed(String),
202}
203
204pub async fn start_rebase(repo_dir: &Path, base_branch: &str) -> RebaseOutcome {
211 if let Err(e) = run_git(repo_dir, &["fetch", "origin", base_branch]).await {
212 return RebaseOutcome::Failed(format!("failed to fetch {base_branch}: {e}"));
213 }
214
215 let target = format!("origin/{base_branch}");
216
217 let no_editor = [("GIT_EDITOR", "true")];
218 if run_git_with_env(repo_dir, &["rebase", &target], &no_editor).await.is_ok() {
219 return RebaseOutcome::Clean;
220 }
221
222 let conflicting = conflicting_files(repo_dir).await;
224 if conflicting.is_empty() {
225 match skip_empty_rebase_commits(repo_dir).await {
228 Ok(None) => return RebaseOutcome::Clean,
229 Ok(Some(files)) => return RebaseOutcome::RebaseConflicts(files),
230 Err(e) => return RebaseOutcome::Failed(format!("{e:#}")),
231 }
232 }
233 RebaseOutcome::RebaseConflicts(conflicting)
234}
235
236pub async fn conflicting_files(repo_dir: &Path) -> Vec<String> {
242 run_git(repo_dir, &["diff", "--name-only", "--diff-filter=U"])
243 .await
244 .map_or_else(|_| vec![], |output| output.lines().map(String::from).collect())
245}
246
247pub async fn files_with_conflict_markers(repo_dir: &Path, files: &[String]) -> Vec<String> {
253 let mut unresolved = Vec::new();
254 for file in files {
255 let path = repo_dir.join(file);
256 if let Ok(content) = tokio::fs::read_to_string(&path).await {
257 if content.contains("<<<<<<<") || content.contains(">>>>>>>") {
258 unresolved.push(file.clone());
259 }
260 }
261 }
262 unresolved
263}
264
265pub async fn abort_rebase(repo_dir: &Path) {
267 let _ = run_git(repo_dir, &["rebase", "--abort"]).await;
268}
269
270async fn skip_empty_rebase_commits(repo_dir: &Path) -> Result<Option<Vec<String>>> {
281 const MAX_SKIPS: u32 = 10;
282 let no_editor = [("GIT_EDITOR", "true")];
283
284 for _ in 0..MAX_SKIPS {
285 if run_git_with_env(repo_dir, &["rebase", "--skip"], &no_editor).await.is_ok() {
286 return Ok(None);
287 }
288
289 let conflicts = conflicting_files(repo_dir).await;
291 if !conflicts.is_empty() {
292 return Ok(Some(conflicts));
293 }
294 }
295
296 abort_rebase(repo_dir).await;
297 anyhow::bail!("rebase had too many empty commits (skipped {MAX_SKIPS} times)")
298}
299
300pub async fn rebase_continue(
306 repo_dir: &Path,
307 conflicting: &[String],
308) -> Result<Option<Vec<String>>> {
309 for file in conflicting {
310 run_git(repo_dir, &["add", "--", file]).await.with_context(|| format!("staging {file}"))?;
311 }
312
313 let no_editor = [("GIT_EDITOR", "true")];
314 if run_git_with_env(repo_dir, &["rebase", "--continue"], &no_editor).await.is_ok() {
315 return Ok(None);
316 }
317
318 let new_conflicts = conflicting_files(repo_dir).await;
320 if new_conflicts.is_empty() {
321 return skip_empty_rebase_commits(repo_dir).await;
323 }
324 Ok(Some(new_conflicts))
325}
326
327pub async fn default_branch(repo_dir: &Path) -> Result<String> {
329 if let Ok(output) = run_git(repo_dir, &["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
331 if let Some(branch) = output.strip_prefix("refs/remotes/origin/") {
332 return Ok(branch.to_string());
333 }
334 }
335
336 if run_git(repo_dir, &["rev-parse", "--verify", "main"]).await.is_ok() {
338 return Ok("main".to_string());
339 }
340 if run_git(repo_dir, &["rev-parse", "--verify", "master"]).await.is_ok() {
341 return Ok("master".to_string());
342 }
343
344 let output = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
346 .await
347 .context("detecting default branch")?;
348 Ok(output)
349}
350
351pub async fn head_sha(repo_dir: &Path) -> Result<String> {
353 run_git(repo_dir, &["rev-parse", "HEAD"]).await.context("getting HEAD sha")
354}
355
356pub async fn commit_count_since(repo_dir: &Path, since_ref: &str) -> Result<u32> {
358 let output = run_git(repo_dir, &["rev-list", "--count", &format!("{since_ref}..HEAD")])
359 .await
360 .context("counting commits since ref")?;
361 output.parse::<u32>().context("parsing commit count")
362}
363
364pub async fn changed_files_since(repo_dir: &Path, since_ref: &str) -> Result<Vec<String>> {
366 let output = run_git(repo_dir, &["diff", "--name-only", since_ref, "HEAD"])
367 .await
368 .context("listing changed files since ref")?;
369 Ok(output.lines().filter(|l| !l.is_empty()).map(String::from).collect())
370}
371
372async fn run_git(repo_dir: &Path, args: &[&str]) -> Result<String> {
373 run_git_with_env(repo_dir, args, &[]).await
374}
375
376async fn run_git_with_env(repo_dir: &Path, args: &[&str], env: &[(&str, &str)]) -> Result<String> {
377 let mut cmd = Command::new("git");
378 cmd.args(args).current_dir(repo_dir).kill_on_drop(true);
379 for (k, v) in env {
380 cmd.env(k, v);
381 }
382 let output = cmd.output().await.context("spawning git")?;
383
384 if !output.status.success() {
385 let stderr = String::from_utf8_lossy(&output.stderr);
386 anyhow::bail!("git {} failed: {}", args.join(" "), stderr.trim());
387 }
388
389 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 async fn init_temp_repo() -> tempfile::TempDir {
397 let dir = tempfile::tempdir().unwrap();
398
399 Command::new("git").args(["init"]).current_dir(dir.path()).output().await.unwrap();
401
402 Command::new("git")
403 .args(["config", "user.email", "test@test.com"])
404 .current_dir(dir.path())
405 .output()
406 .await
407 .unwrap();
408
409 Command::new("git")
410 .args(["config", "user.name", "Test"])
411 .current_dir(dir.path())
412 .output()
413 .await
414 .unwrap();
415
416 tokio::fs::write(dir.path().join("README.md"), "hello").await.unwrap();
417
418 Command::new("git").args(["add", "."]).current_dir(dir.path()).output().await.unwrap();
419
420 Command::new("git")
421 .args(["commit", "-m", "initial"])
422 .current_dir(dir.path())
423 .output()
424 .await
425 .unwrap();
426
427 dir
428 }
429
430 async fn init_temp_repo_with_remote() -> (tempfile::TempDir, tempfile::TempDir) {
433 let dir = init_temp_repo().await;
434
435 let remote_dir = tempfile::tempdir().unwrap();
436 Command::new("git")
437 .args(["clone", "--bare", &dir.path().to_string_lossy(), "."])
438 .current_dir(remote_dir.path())
439 .output()
440 .await
441 .unwrap();
442 run_git(dir.path(), &["remote", "add", "origin", &remote_dir.path().to_string_lossy()])
443 .await
444 .unwrap();
445 run_git(dir.path(), &["fetch", "origin"]).await.unwrap();
446
447 (dir, remote_dir)
448 }
449
450 #[tokio::test]
451 async fn create_and_remove_worktree() {
452 let (dir, _remote) = init_temp_repo_with_remote().await;
453
454 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
456
457 let wt = create_worktree(dir.path(), 42, &branch).await.unwrap();
458 assert!(wt.path.exists());
459 assert!(wt.branch.starts_with("oven/issue-42-"));
460 assert_eq!(wt.issue_number, 42);
461
462 remove_worktree(dir.path(), &wt.path).await.unwrap();
463 assert!(!wt.path.exists());
464 }
465
466 #[tokio::test]
467 async fn list_worktrees_includes_created() {
468 let (dir, _remote) = init_temp_repo_with_remote().await;
469 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
470
471 let _wt = create_worktree(dir.path(), 99, &branch).await.unwrap();
472
473 let worktrees = list_worktrees(dir.path()).await.unwrap();
474 assert!(worktrees.len() >= 2);
476 assert!(
477 worktrees
478 .iter()
479 .any(|w| { w.branch.as_deref().is_some_and(|b| b.starts_with("oven/issue-99-")) })
480 );
481 }
482
483 #[tokio::test]
484 async fn branch_naming_convention() {
485 let name = branch_name(123);
486 assert!(name.starts_with("oven/issue-123-"));
487 assert_eq!(name.len(), "oven/issue-123-".len() + 8);
488 let hex_part = &name["oven/issue-123-".len()..];
490 assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
491 }
492
493 #[tokio::test]
494 async fn default_branch_detection() {
495 let dir = init_temp_repo().await;
496 let branch = default_branch(dir.path()).await.unwrap();
497 assert!(branch == "main" || branch == "master", "got: {branch}");
499 }
500
501 #[tokio::test]
502 async fn error_on_non_git_dir() {
503 let dir = tempfile::tempdir().unwrap();
504 let result = list_worktrees(dir.path()).await;
505 assert!(result.is_err());
506 }
507
508 #[tokio::test]
509 async fn force_push_branch_works() {
510 let dir = init_temp_repo().await;
511
512 let remote_dir = tempfile::tempdir().unwrap();
514 Command::new("git")
515 .args(["clone", "--bare", &dir.path().to_string_lossy(), "."])
516 .current_dir(remote_dir.path())
517 .output()
518 .await
519 .unwrap();
520
521 run_git(dir.path(), &["remote", "add", "origin", &remote_dir.path().to_string_lossy()])
522 .await
523 .unwrap();
524
525 run_git(dir.path(), &["checkout", "-b", "test-branch"]).await.unwrap();
527 tokio::fs::write(dir.path().join("new.txt"), "v1").await.unwrap();
528 run_git(dir.path(), &["add", "."]).await.unwrap();
529 run_git(dir.path(), &["commit", "-m", "v1"]).await.unwrap();
530 push_branch(dir.path(), "test-branch").await.unwrap();
531
532 tokio::fs::write(dir.path().join("new.txt"), "v2").await.unwrap();
534 run_git(dir.path(), &["add", "."]).await.unwrap();
535 run_git(dir.path(), &["commit", "--amend", "-m", "v2"]).await.unwrap();
536
537 assert!(push_branch(dir.path(), "test-branch").await.is_err());
539 assert!(force_push_branch(dir.path(), "test-branch").await.is_ok());
540 }
541
542 #[tokio::test]
543 async fn start_rebase_clean() {
544 let dir = init_temp_repo().await;
545 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
546
547 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
548 tokio::fs::write(dir.path().join("feature.txt"), "feature work").await.unwrap();
549 run_git(dir.path(), &["add", "."]).await.unwrap();
550 run_git(dir.path(), &["commit", "-m", "feature commit"]).await.unwrap();
551
552 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
553 tokio::fs::write(dir.path().join("base.txt"), "base work").await.unwrap();
554 run_git(dir.path(), &["add", "."]).await.unwrap();
555 run_git(dir.path(), &["commit", "-m", "base commit"]).await.unwrap();
556
557 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
558 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
559 .await
560 .unwrap();
561
562 let outcome = start_rebase(dir.path(), &branch).await;
563 assert!(matches!(outcome, RebaseOutcome::Clean));
564 assert!(dir.path().join("feature.txt").exists());
565 assert!(dir.path().join("base.txt").exists());
566 }
567
568 #[tokio::test]
569 async fn start_rebase_conflicts() {
570 let dir = init_temp_repo().await;
571 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
572
573 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
575 tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
576 run_git(dir.path(), &["add", "."]).await.unwrap();
577 run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
578
579 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
581 tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
582 run_git(dir.path(), &["add", "."]).await.unwrap();
583 run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
584
585 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
586 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
587 .await
588 .unwrap();
589
590 let outcome = start_rebase(dir.path(), &branch).await;
591 assert!(
592 matches!(outcome, RebaseOutcome::RebaseConflicts(_)),
593 "expected RebaseConflicts, got {outcome:?}"
594 );
595
596 assert!(
598 dir.path().join(".git/rebase-merge").exists()
599 || dir.path().join(".git/rebase-apply").exists()
600 );
601
602 abort_rebase(dir.path()).await;
604 }
605
606 #[tokio::test]
607 async fn start_rebase_no_remote_fails() {
608 let dir = init_temp_repo().await;
609 let outcome = start_rebase(dir.path(), "main").await;
611 assert!(matches!(outcome, RebaseOutcome::Failed(_)));
612 }
613
614 #[tokio::test]
615 async fn rebase_continue_resolves_conflict() {
616 let dir = init_temp_repo().await;
617 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
618
619 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
620 tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
621 run_git(dir.path(), &["add", "."]).await.unwrap();
622 run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
623
624 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
625 tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
626 run_git(dir.path(), &["add", "."]).await.unwrap();
627 run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
628
629 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
630 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
631 .await
632 .unwrap();
633
634 let outcome = start_rebase(dir.path(), &branch).await;
635 let files = match outcome {
636 RebaseOutcome::RebaseConflicts(f) => f,
637 other => panic!("expected RebaseConflicts, got {other:?}"),
638 };
639
640 tokio::fs::write(dir.path().join("README.md"), "resolved version").await.unwrap();
642
643 let result = rebase_continue(dir.path(), &files).await.unwrap();
644 assert!(result.is_none(), "expected rebase to complete, got more conflicts");
645
646 assert!(!dir.path().join(".git/rebase-merge").exists());
648 assert!(!dir.path().join(".git/rebase-apply").exists());
649 }
650
651 #[tokio::test]
652 async fn start_rebase_skips_empty_commit() {
653 let dir = init_temp_repo().await;
654 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
655
656 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
658 tokio::fs::write(dir.path().join("README.md"), "changed").await.unwrap();
659 run_git(dir.path(), &["add", "."]).await.unwrap();
660 run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
661
662 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
664 tokio::fs::write(dir.path().join("README.md"), "changed").await.unwrap();
665 run_git(dir.path(), &["add", "."]).await.unwrap();
666 run_git(dir.path(), &["commit", "-m", "same change on base"]).await.unwrap();
667
668 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
669 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
670 .await
671 .unwrap();
672
673 let outcome = start_rebase(dir.path(), &branch).await;
675 assert!(
676 matches!(outcome, RebaseOutcome::Clean),
677 "expected Clean after skipping empty commit, got {outcome:?}"
678 );
679
680 assert!(!dir.path().join(".git/rebase-merge").exists());
682 assert!(!dir.path().join(".git/rebase-apply").exists());
683 }
684
685 #[tokio::test]
686 async fn rebase_continue_skips_empty_commit_after_real_conflict() {
687 let dir = init_temp_repo().await;
688 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
689
690 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
692 tokio::fs::write(dir.path().join("README.md"), "feature readme").await.unwrap();
693 run_git(dir.path(), &["add", "."]).await.unwrap();
694 run_git(dir.path(), &["commit", "-m", "feature readme"]).await.unwrap();
695
696 tokio::fs::write(dir.path().join("other.txt"), "feature other").await.unwrap();
697 run_git(dir.path(), &["add", "."]).await.unwrap();
698 run_git(dir.path(), &["commit", "-m", "feature other"]).await.unwrap();
699
700 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
702 tokio::fs::write(dir.path().join("README.md"), "base readme").await.unwrap();
703 run_git(dir.path(), &["add", "."]).await.unwrap();
704 run_git(dir.path(), &["commit", "-m", "base readme"]).await.unwrap();
705
706 tokio::fs::write(dir.path().join("other.txt"), "feature other").await.unwrap();
707 run_git(dir.path(), &["add", "."]).await.unwrap();
708 run_git(dir.path(), &["commit", "-m", "same other on base"]).await.unwrap();
709
710 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
711 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
712 .await
713 .unwrap();
714
715 let outcome = start_rebase(dir.path(), &branch).await;
717 let files = match outcome {
718 RebaseOutcome::RebaseConflicts(f) => f,
719 other => panic!("expected RebaseConflicts, got {other:?}"),
720 };
721 assert!(files.contains(&"README.md".to_string()));
722
723 tokio::fs::write(dir.path().join("README.md"), "resolved").await.unwrap();
725
726 let result = rebase_continue(dir.path(), &files).await.unwrap();
728 assert!(result.is_none(), "expected rebase to complete, got more conflicts");
729
730 assert!(!dir.path().join(".git/rebase-merge").exists());
731 assert!(!dir.path().join(".git/rebase-apply").exists());
732 }
733
734 #[tokio::test]
735 async fn conflict_markers_detected_in_content() {
736 let dir = tempfile::tempdir().unwrap();
737 let with_markers = "line 1\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nline 2";
738 let without_markers = "line 1\nresolved content\nline 2";
739
740 tokio::fs::write(dir.path().join("conflicted.txt"), with_markers).await.unwrap();
741 tokio::fs::write(dir.path().join("resolved.txt"), without_markers).await.unwrap();
742
743 let files = vec!["conflicted.txt".to_string(), "resolved.txt".to_string()];
744 let result = files_with_conflict_markers(dir.path(), &files).await;
745
746 assert_eq!(result, vec!["conflicted.txt"]);
747 }
748
749 #[tokio::test]
750 async fn conflict_markers_empty_when_all_resolved() {
751 let dir = tempfile::tempdir().unwrap();
752 tokio::fs::write(dir.path().join("a.txt"), "clean content").await.unwrap();
753 tokio::fs::write(dir.path().join("b.txt"), "also clean").await.unwrap();
754
755 let files = vec!["a.txt".to_string(), "b.txt".to_string()];
756 let result = files_with_conflict_markers(dir.path(), &files).await;
757
758 assert!(result.is_empty());
759 }
760
761 #[tokio::test]
762 async fn conflict_markers_missing_file_skipped() {
763 let dir = tempfile::tempdir().unwrap();
764 let files = vec!["nonexistent.txt".to_string()];
765 let result = files_with_conflict_markers(dir.path(), &files).await;
766
767 assert!(result.is_empty());
768 }
769
770 #[tokio::test]
771 async fn resolved_file_still_unmerged_in_index() {
772 let dir = init_temp_repo().await;
776 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
777
778 run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
779 tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
780 run_git(dir.path(), &["add", "."]).await.unwrap();
781 run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
782
783 run_git(dir.path(), &["checkout", &branch]).await.unwrap();
784 tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
785 run_git(dir.path(), &["add", "."]).await.unwrap();
786 run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
787
788 run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
789 run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
790 .await
791 .unwrap();
792
793 let outcome = start_rebase(dir.path(), &branch).await;
794 let files = match outcome {
795 RebaseOutcome::RebaseConflicts(f) => f,
796 other => panic!("expected RebaseConflicts, got {other:?}"),
797 };
798
799 tokio::fs::write(dir.path().join("README.md"), "resolved version").await.unwrap();
801
802 let index_conflicts = conflicting_files(dir.path()).await;
804 assert!(
805 !index_conflicts.is_empty(),
806 "file should still be Unmerged in git index before git add"
807 );
808
809 let content_conflicts = files_with_conflict_markers(dir.path(), &files).await;
811 assert!(
812 content_conflicts.is_empty(),
813 "file content has no conflict markers, should be empty"
814 );
815
816 abort_rebase(dir.path()).await;
818 }
819
820 #[tokio::test]
821 async fn fetch_branch_updates_remote_tracking_ref() {
822 let (dir, remote_dir) = init_temp_repo_with_remote().await;
823 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
824
825 let before_sha =
826 run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
827
828 let _other = push_remote_commit(remote_dir.path(), &branch, "remote.txt").await;
829
830 let stale_sha =
832 run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
833 assert_eq!(before_sha, stale_sha);
834
835 fetch_branch(dir.path(), &branch).await.unwrap();
837
838 let after_sha =
839 run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
840 assert_ne!(before_sha, after_sha, "origin/{branch} should have advanced after fetch");
841 }
842
843 #[tokio::test]
844 async fn fetch_branch_no_remote_errors() {
845 let dir = init_temp_repo().await;
846 let result = fetch_branch(dir.path(), "main").await;
847 assert!(result.is_err());
848 }
849
850 #[tokio::test]
851 async fn worktree_after_fetch_includes_remote_changes() {
852 let (dir, remote_dir) = init_temp_repo_with_remote().await;
853 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
854
855 let _other = push_remote_commit(remote_dir.path(), &branch, "merged.txt").await;
856
857 fetch_branch(dir.path(), &branch).await.unwrap();
858
859 let wt = create_worktree(dir.path(), 99, &branch).await.unwrap();
860 assert!(
861 wt.path.join("merged.txt").exists(),
862 "worktree should contain the file from the merged PR"
863 );
864 }
865
866 async fn push_remote_commit(
868 remote_dir: &std::path::Path,
869 branch: &str,
870 filename: &str,
871 ) -> tempfile::TempDir {
872 let other = tempfile::tempdir().unwrap();
873 Command::new("git")
874 .args(["clone", &remote_dir.to_string_lossy(), "."])
875 .current_dir(other.path())
876 .output()
877 .await
878 .unwrap();
879 for args in
880 [&["config", "user.email", "test@test.com"][..], &["config", "user.name", "Test"]]
881 {
882 Command::new("git").args(args).current_dir(other.path()).output().await.unwrap();
883 }
884 tokio::fs::write(other.path().join(filename), "content").await.unwrap();
885 run_git(other.path(), &["add", "."]).await.unwrap();
886 run_git(other.path(), &["commit", "-m", &format!("add {filename}")]).await.unwrap();
887 run_git(other.path(), &["push", "origin", branch]).await.unwrap();
888 other
889 }
890
891 #[tokio::test]
892 async fn advance_local_branch_when_checked_out() {
893 let (dir, remote_dir) = init_temp_repo_with_remote().await;
894 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
895
896 let before = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
897
898 let _other = push_remote_commit(remote_dir.path(), &branch, "new.txt").await;
899 fetch_branch(dir.path(), &branch).await.unwrap();
900
901 let after_fetch = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
903 assert_eq!(before, after_fetch, "local branch should not advance from fetch alone");
904
905 advance_local_branch(dir.path(), &branch).await.unwrap();
907
908 let after_advance = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
909 let remote_sha =
910 run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
911 assert_eq!(after_advance, remote_sha, "local branch should match origin after advance");
912 assert!(dir.path().join("new.txt").exists(), "working tree should have the new file");
913 }
914
915 #[tokio::test]
916 async fn advance_local_branch_when_not_checked_out() {
917 let (dir, remote_dir) = init_temp_repo_with_remote().await;
918 let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
919
920 run_git(dir.path(), &["checkout", "-b", "other"]).await.unwrap();
922
923 let before = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
924
925 let _other = push_remote_commit(remote_dir.path(), &branch, "new.txt").await;
926 fetch_branch(dir.path(), &branch).await.unwrap();
927
928 advance_local_branch(dir.path(), &branch).await.unwrap();
929
930 let after = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
931 let remote_sha =
932 run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
933 assert_ne!(before, after, "local branch should have moved");
934 assert_eq!(after, remote_sha, "local branch should match origin after advance");
935 }
936}