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/// Advance the local branch ref to match `origin/<branch>` after a fetch.
153///
154/// If the branch is currently checked out, uses `merge --ff-only` so the
155/// working tree stays in sync. Otherwise updates the ref directly after
156/// verifying the move is a fast-forward. Errors are non-fatal for the
157/// pipeline (which only needs `origin/<branch>`), but keeping the local
158/// branch current avoids surprise "behind by N commits" messages.
159pub 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        // Only update if it's a fast-forward (local is ancestor of remote).
171        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
180/// Force-push a branch to origin using `--force-with-lease` for safety.
181///
182/// Used after rebasing a pipeline branch onto the updated base branch.
183pub 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/// Outcome of a rebase attempt.
190#[derive(Debug)]
191pub enum RebaseOutcome {
192    /// Rebase succeeded cleanly.
193    Clean,
194    /// Rebase had conflicts. The working tree is left in a mid-rebase state
195    /// so the caller can attempt agent-assisted resolution via
196    /// [`rebase_continue`] / [`abort_rebase`].
197    RebaseConflicts(Vec<String>),
198    /// Agent resolved the rebase conflicts.
199    AgentResolved,
200    /// Unrecoverable failure (e.g. fetch failed).
201    Failed(String),
202}
203
204/// Start a rebase of the current branch onto the latest `origin/<base_branch>`.
205///
206/// If the rebase succeeds cleanly, returns `Clean`. If it hits conflicts, the
207/// working tree is left in a mid-rebase state and `RebaseConflicts` is returned
208/// with the list of conflicting files. The caller should resolve them and call
209/// [`rebase_continue`], or [`abort_rebase`] to give up.
210pub 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    // Rebase stopped -- check if it's real conflicts or an empty commit.
223    let conflicting = conflicting_files(repo_dir).await;
224    if conflicting.is_empty() {
225        // No conflicting files means an empty commit (patch already applied).
226        // Skip it rather than sending an agent to resolve nothing.
227        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
236/// List files with unresolved merge conflicts (git index state).
237///
238/// Uses `git diff --diff-filter=U` which checks the index, not file content.
239/// A file stays "Unmerged" until `git add` is run on it, even if conflict
240/// markers have been removed from the working tree.
241pub 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
247/// Check which files still contain conflict markers in their content.
248///
249/// Unlike [`conflicting_files`], this reads the actual working tree content
250/// rather than relying on git index state. This is the correct check after an
251/// agent edits files to resolve conflicts but before `git add` is run.
252pub 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
265/// Abort an in-progress rebase.
266pub async fn abort_rebase(repo_dir: &Path) {
267    let _ = run_git(repo_dir, &["rebase", "--abort"]).await;
268}
269
270/// Skip empty commits during a rebase when no conflicting files are present.
271///
272/// During rebase replay, a commit can become empty if its changes are already
273/// present on the target branch. Git stops on these by default. This function
274/// runs `git rebase --skip` in a loop until the rebase completes or real
275/// conflicts appear.
276///
277/// Returns `Ok(None)` if the rebase completed after skipping.
278/// Returns `Ok(Some(files))` if real conflicts appeared after a skip.
279/// Returns `Err` if the maximum number of skips was exhausted.
280async 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        // Skip stopped again -- check for real conflicts vs another empty commit.
290        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
300/// Stage resolved conflict files and continue the in-progress rebase.
301///
302/// Returns `Ok(None)` if the rebase completed successfully after continuing.
303/// Returns `Ok(Some(files))` if continuing hit new conflicts on the next commit.
304/// Returns `Err` on unexpected failures.
305pub 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    // rebase --continue stopped again -- new conflicts on the next commit.
319    let new_conflicts = conflicting_files(repo_dir).await;
320    if new_conflicts.is_empty() {
321        // Empty commit after continue -- skip it.
322        return skip_empty_rebase_commits(repo_dir).await;
323    }
324    Ok(Some(new_conflicts))
325}
326
327/// Get the default branch name (main or master).
328pub async fn default_branch(repo_dir: &Path) -> Result<String> {
329    // Try symbolic-ref first
330    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    // Fallback: check if main exists, otherwise master
337    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    // Last resort: whatever HEAD points to
345    let output = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
346        .await
347        .context("detecting default branch")?;
348    Ok(output)
349}
350
351/// Get the current HEAD commit SHA.
352pub async fn head_sha(repo_dir: &Path) -> Result<String> {
353    run_git(repo_dir, &["rev-parse", "HEAD"]).await.context("getting HEAD sha")
354}
355
356/// Count commits between a ref and HEAD.
357pub 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
364/// List files changed between a ref and HEAD.
365pub 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        // Init a repo with an initial commit so we have a branch to work from
400        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    /// Create a temp repo with a bare remote so `origin/<branch>` exists.
431    /// Returns (repo dir, remote dir) -- both must be kept alive for the test.
432    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        // Detect the current branch name
455        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        // Should have at least the main worktree + the one we created
475        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        // The hex part should be valid hex
489        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        // git init creates "main" or "master" depending on config
498        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        // Set up a bare remote to push to
513        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        // Create a branch, push it, then amend and force-push
526        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        // Amend the commit (simulating a rebase)
533        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        // Regular push should fail, force push should succeed
538        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        // Create a feature branch that modifies README.md
574        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        // Create a conflicting change on the base branch
580        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        // Tree should be in mid-rebase state
597        assert!(
598            dir.path().join(".git/rebase-merge").exists()
599                || dir.path().join(".git/rebase-apply").exists()
600        );
601
602        // Clean up
603        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        // No remote configured, so fetch will fail
610        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        // Manually resolve the conflict
641        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        // Verify rebase completed (no rebase-merge dir)
647        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        // Create a feature branch with a change
657        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        // Cherry-pick the same change onto base so the feature commit becomes empty
663        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        // Rebase should skip the empty commit and succeed
674        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        // Verify rebase completed
681        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        // Feature branch: commit 1 changes README, commit 2 changes other.txt
691        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        // Base branch: conflict on README, cherry-pick the same other.txt change
701        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        // First commit (README) will have a real conflict
716        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        // Resolve the conflict manually
724        tokio::fs::write(dir.path().join("README.md"), "resolved").await.unwrap();
725
726        // Continue should resolve the conflict, then skip the empty second commit
727        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        // Reproduces the root cause: agent resolves conflict markers in the
773        // working tree, but git index still shows the file as Unmerged because
774        // `git add` hasn't been run yet.
775        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        // Resolve the conflict markers in the working tree (simulating what an agent does)
800        tokio::fs::write(dir.path().join("README.md"), "resolved version").await.unwrap();
801
802        // Index-based check still sees the file as Unmerged (the old broken check)
803        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        // Content-based check correctly sees no conflict markers
810        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        // Clean up
817        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        // Remote tracking ref should still be at the old SHA
831        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 should update the remote tracking ref
836        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    /// Helper: push a commit from a separate clone so the local repo falls behind.
867    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        // Local branch should still be behind after fetch
902        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 should fast-forward it
906        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        // Switch to a different branch so the base branch is not checked out
921        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}