Skip to main content

oven_cli/git/
mod.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use tokio::process::Command;
5
6/// A git worktree created for an issue pipeline.
7#[derive(Debug, Clone)]
8pub struct Worktree {
9    pub path: PathBuf,
10    pub branch: String,
11    pub issue_number: u32,
12}
13
14/// Info about an existing worktree from `git worktree list`.
15#[derive(Debug, Clone)]
16pub struct WorktreeInfo {
17    pub path: PathBuf,
18    pub branch: Option<String>,
19}
20
21/// Generate a branch name for an issue: `oven/issue-{number}-{short_hex}`.
22fn 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
27/// Create a worktree for the given issue, branching from `origin/<base_branch>`.
28///
29/// Uses the remote tracking ref rather than the local branch so that worktrees
30/// always start from the latest remote state (e.g. after PRs are merged).
31pub 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    // Ensure parent directory exists
41    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
56/// Remove a worktree by path.
57pub 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
64/// List all worktrees in the repository.
65pub 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            // Save previous worktree if we have one
77            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            // Extract branch name from refs/heads/...
83            current_branch =
84                Some(branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref).to_string());
85        }
86    }
87
88    // Don't forget the last one
89    if let Some(path) = current_path {
90        worktrees.push(WorktreeInfo { path, branch: current_branch });
91    }
92
93    Ok(worktrees)
94}
95
96/// Prune stale worktrees and return the count pruned.
97pub 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
106/// Delete a local branch.
107pub 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
112/// List merged branches matching `oven/*`.
113pub 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
127/// Create an empty commit (used to seed a branch before PR creation).
128pub 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
135/// Push a branch to origin.
136pub 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
141/// Fetch a branch from origin to update the remote tracking ref.
142///
143/// Used between pipeline layers so that new worktrees (which branch from
144/// `origin/<branch>`) start from post-merge state.
145pub 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
152/// Force-push a branch to origin using `--force-with-lease` for safety.
153///
154/// Used after rebasing a pipeline branch onto the updated base branch.
155pub 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/// Outcome of a rebase attempt.
162#[derive(Debug)]
163pub enum RebaseOutcome {
164    /// Rebase succeeded cleanly.
165    Clean,
166    /// Rebase had conflicts. The working tree is left in a mid-rebase state
167    /// so the caller can attempt agent-assisted resolution via
168    /// [`rebase_continue`] / [`abort_rebase`].
169    RebaseConflicts(Vec<String>),
170    /// Agent resolved the rebase conflicts.
171    AgentResolved,
172    /// Unrecoverable failure (e.g. fetch failed).
173    Failed(String),
174}
175
176/// Start a rebase of the current branch onto the latest `origin/<base_branch>`.
177///
178/// If the rebase succeeds cleanly, returns `Clean`. If it hits conflicts, the
179/// working tree is left in a mid-rebase state and `RebaseConflicts` is returned
180/// with the list of conflicting files. The caller should resolve them and call
181/// [`rebase_continue`], or [`abort_rebase`] to give up.
182pub 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    // Rebase stopped -- check if it's real conflicts or an empty commit.
195    let conflicting = conflicting_files(repo_dir).await;
196    if conflicting.is_empty() {
197        // No conflicting files means an empty commit (patch already applied).
198        // Skip it rather than sending an agent to resolve nothing.
199        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
208/// List files with unresolved merge conflicts (git index state).
209///
210/// Uses `git diff --diff-filter=U` which checks the index, not file content.
211/// A file stays "Unmerged" until `git add` is run on it, even if conflict
212/// markers have been removed from the working tree.
213pub 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
219/// Check which files still contain conflict markers in their content.
220///
221/// Unlike [`conflicting_files`], this reads the actual working tree content
222/// rather than relying on git index state. This is the correct check after an
223/// agent edits files to resolve conflicts but before `git add` is run.
224pub 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
237/// Abort an in-progress rebase.
238pub async fn abort_rebase(repo_dir: &Path) {
239    let _ = run_git(repo_dir, &["rebase", "--abort"]).await;
240}
241
242/// Skip empty commits during a rebase when no conflicting files are present.
243///
244/// During rebase replay, a commit can become empty if its changes are already
245/// present on the target branch. Git stops on these by default. This function
246/// runs `git rebase --skip` in a loop until the rebase completes or real
247/// conflicts appear.
248///
249/// Returns `Ok(None)` if the rebase completed after skipping.
250/// Returns `Ok(Some(files))` if real conflicts appeared after a skip.
251/// Returns `Err` if the maximum number of skips was exhausted.
252async 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        // Skip stopped again -- check for real conflicts vs another empty commit.
262        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
272/// Stage resolved conflict files and continue the in-progress rebase.
273///
274/// Returns `Ok(None)` if the rebase completed successfully after continuing.
275/// Returns `Ok(Some(files))` if continuing hit new conflicts on the next commit.
276/// Returns `Err` on unexpected failures.
277pub 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    // rebase --continue stopped again -- new conflicts on the next commit.
291    let new_conflicts = conflicting_files(repo_dir).await;
292    if new_conflicts.is_empty() {
293        // Empty commit after continue -- skip it.
294        return skip_empty_rebase_commits(repo_dir).await;
295    }
296    Ok(Some(new_conflicts))
297}
298
299/// Get the default branch name (main or master).
300pub async fn default_branch(repo_dir: &Path) -> Result<String> {
301    // Try symbolic-ref first
302    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    // Fallback: check if main exists, otherwise master
309    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    // Last resort: whatever HEAD points to
317    let output = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
318        .await
319        .context("detecting default branch")?;
320    Ok(output)
321}
322
323/// Get the current HEAD commit SHA.
324pub async fn head_sha(repo_dir: &Path) -> Result<String> {
325    run_git(repo_dir, &["rev-parse", "HEAD"]).await.context("getting HEAD sha")
326}
327
328/// Count commits between a ref and HEAD.
329pub 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
336/// List files changed between a ref and HEAD.
337pub 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        // Init a repo with an initial commit so we have a branch to work from
372        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    /// Create a temp repo with a bare remote so `origin/<branch>` exists.
403    /// Returns (repo dir, remote dir) -- both must be kept alive for the test.
404    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        // Detect the current branch name
427        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        // Should have at least the main worktree + the one we created
447        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        // The hex part should be valid hex
461        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        // git init creates "main" or "master" depending on config
470        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        // Set up a bare remote to push to
485        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        // Create a branch, push it, then amend and force-push
498        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        // Amend the commit (simulating a rebase)
505        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        // Regular push should fail, force push should succeed
510        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        // Create a feature branch that modifies README.md
546        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        // Create a conflicting change on the base branch
552        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        // Tree should be in mid-rebase state
569        assert!(
570            dir.path().join(".git/rebase-merge").exists()
571                || dir.path().join(".git/rebase-apply").exists()
572        );
573
574        // Clean up
575        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        // No remote configured, so fetch will fail
582        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        // Manually resolve the conflict
613        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        // Verify rebase completed (no rebase-merge dir)
619        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        // Create a feature branch with a change
629        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        // Cherry-pick the same change onto base so the feature commit becomes empty
635        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        // Rebase should skip the empty commit and succeed
646        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        // Verify rebase completed
653        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        // Feature branch: commit 1 changes README, commit 2 changes other.txt
663        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        // Base branch: conflict on README, cherry-pick the same other.txt change
673        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        // First commit (README) will have a real conflict
688        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        // Resolve the conflict manually
696        tokio::fs::write(dir.path().join("README.md"), "resolved").await.unwrap();
697
698        // Continue should resolve the conflict, then skip the empty second commit
699        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        // Reproduces the root cause: agent resolves conflict markers in the
745        // working tree, but git index still shows the file as Unmerged because
746        // `git add` hasn't been run yet.
747        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        // Resolve the conflict markers in the working tree (simulating what an agent does)
772        tokio::fs::write(dir.path().join("README.md"), "resolved version").await.unwrap();
773
774        // Index-based check still sees the file as Unmerged (the old broken check)
775        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        // Content-based check correctly sees no conflict markers
782        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        // Clean up
789        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        // Set up a bare remote
798        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        // Initial fetch to create the tracking ref
810        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        // Simulate a remote advance: clone the bare repo, commit, push
815        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        // Remote tracking ref should still be at the old SHA
840        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 should update the remote tracking ref
845        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        // Simulate a remote advance (like a merged PR)
865        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 to update origin/<branch>
890        fetch_branch(dir.path(), &branch).await.unwrap();
891
892        // Create a worktree -- it should include the remote change
893        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}