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