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/// Check if the worktree has any uncommitted changes, including untracked files.
136pub async fn is_dirty(repo_dir: &Path) -> Result<bool> {
137    let output =
138        run_git(repo_dir, &["status", "--porcelain"]).await.context("checking dirty state")?;
139    Ok(!output.is_empty())
140}
141
142/// Stage modified/deleted tracked files and commit them with the given message.
143///
144/// Used to preserve agent work (e.g. fixer edits) that wasn't committed before
145/// the rebase step. Uses `git add -u` (tracked files only) rather than `git add -A`
146/// to avoid committing untracked artifacts like temp files or test output.
147///
148/// Checks for tracked changes with `-uno` (ignore untracked) so that worktrees
149/// with only untracked files don't trigger a commit attempt that would fail.
150///
151/// Returns `Ok(true)` if a commit was created, `Ok(false)` if there was nothing
152/// to commit.
153pub async fn commit_all(repo_dir: &Path, message: &str) -> Result<bool> {
154    let tracked_changes = run_git(repo_dir, &["status", "--porcelain", "-uno"])
155        .await
156        .context("checking tracked changes")?;
157    if tracked_changes.is_empty() {
158        return Ok(false);
159    }
160    run_git(repo_dir, &["add", "-u"]).await.context("staging tracked changes")?;
161    run_git(repo_dir, &["commit", "-m", message]).await.context("committing changes")?;
162    Ok(true)
163}
164
165/// Push a branch to origin.
166pub async fn push_branch(repo_dir: &Path, branch: &str) -> Result<()> {
167    run_git(repo_dir, &["push", "origin", branch]).await.context("pushing branch")?;
168    Ok(())
169}
170
171/// Fetch a branch from origin to update the remote tracking ref.
172///
173/// Used between pipeline layers so that new worktrees (which branch from
174/// `origin/<branch>`) start from post-merge state. Retries once on transient
175/// ref lock contention from parallel fetches.
176pub async fn fetch_branch(repo_dir: &Path, branch: &str) -> Result<()> {
177    fetch_with_retry(repo_dir, branch)
178        .await
179        .with_context(|| format!("fetching {branch} from origin"))
180}
181
182/// Advance the local branch ref to match `origin/<branch>` after a fetch.
183///
184/// If the branch is currently checked out, uses `merge --ff-only` so the
185/// working tree stays in sync. Otherwise updates the ref directly after
186/// verifying the move is a fast-forward. Errors are non-fatal for the
187/// pipeline (which only needs `origin/<branch>`), but keeping the local
188/// branch current avoids surprise "behind by N commits" messages.
189pub async fn advance_local_branch(repo_dir: &Path, branch: &str) -> Result<()> {
190    let remote_ref = format!("origin/{branch}");
191    let current = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
192        .await
193        .context("detecting current branch")?;
194
195    if current == branch {
196        run_git(repo_dir, &["merge", "--ff-only", &remote_ref])
197            .await
198            .context("fast-forwarding checked-out branch")?;
199    } else {
200        // Only update if it's a fast-forward (local is ancestor of remote).
201        if run_git(repo_dir, &["merge-base", "--is-ancestor", branch, &remote_ref]).await.is_ok() {
202            run_git(repo_dir, &["branch", "-f", branch, &remote_ref])
203                .await
204                .context("updating local branch ref")?;
205        }
206    }
207    Ok(())
208}
209
210/// Force-push a branch to origin using `--force-with-lease` for safety.
211///
212/// Used after rebasing a pipeline branch onto the updated base branch.
213pub async fn force_push_branch(repo_dir: &Path, branch: &str) -> Result<()> {
214    let lease = format!("--force-with-lease=refs/heads/{branch}");
215    run_git(repo_dir, &["push", &lease, "origin", branch]).await.context("force-pushing branch")?;
216    Ok(())
217}
218
219/// Outcome of a rebase attempt.
220#[derive(Debug)]
221pub enum RebaseOutcome {
222    /// Rebase succeeded cleanly.
223    Clean,
224    /// Rebase had conflicts. The working tree is left in a mid-rebase state
225    /// so the caller can attempt agent-assisted resolution via
226    /// [`rebase_continue`] / [`abort_rebase`].
227    RebaseConflicts(Vec<String>),
228    /// Agent resolved the rebase conflicts.
229    AgentResolved,
230    /// Unrecoverable failure (e.g. fetch failed).
231    Failed(String),
232}
233
234/// Start a rebase of the current branch onto the latest `origin/<base_branch>`.
235///
236/// If the rebase succeeds cleanly, returns `Clean`. If it hits conflicts, the
237/// working tree is left in a mid-rebase state and `RebaseConflicts` is returned
238/// with the list of conflicting files. The caller should resolve them and call
239/// [`rebase_continue`], or [`abort_rebase`] to give up.
240///
241/// Uses `--empty=drop` so commits that become empty after rebase (because their
242/// changes are already on the target branch) are silently dropped instead of
243/// stopping the rebase.
244pub async fn start_rebase(repo_dir: &Path, base_branch: &str) -> RebaseOutcome {
245    if let Err(e) = fetch_with_retry(repo_dir, base_branch).await {
246        return RebaseOutcome::Failed(format!("failed to fetch {base_branch}: {e}"));
247    }
248
249    let target = format!("origin/{base_branch}");
250
251    let no_editor = [("GIT_EDITOR", "true")];
252    let Err(rebase_err) =
253        run_git_with_env(repo_dir, &["rebase", "--empty=drop", &target], &no_editor).await
254    else {
255        return RebaseOutcome::Clean;
256    };
257
258    // Rebase command failed. Check if a rebase is actually in progress -- if not,
259    // the failure was something else entirely (dirty worktree, invalid ref, etc.)
260    // and we should report the real error rather than misclassifying it.
261    if !rebase_in_progress(repo_dir).await {
262        return RebaseOutcome::Failed(format!("rebase could not start: {rebase_err}"));
263    }
264
265    let conflicting = conflicting_files(repo_dir).await;
266    if conflicting.is_empty() {
267        // Rebase is in progress but no conflicting files. This shouldn't happen
268        // with --empty=drop (empty commits are auto-dropped), but handle it
269        // gracefully by aborting and reporting the unexpected state.
270        abort_rebase(repo_dir).await;
271        return RebaseOutcome::Failed(
272            "rebase stopped with no conflicts and no empty commits".to_string(),
273        );
274    }
275    RebaseOutcome::RebaseConflicts(conflicting)
276}
277
278/// List files with unresolved merge conflicts (git index state).
279///
280/// Uses `git diff --diff-filter=U` which checks the index, not file content.
281/// A file stays "Unmerged" until `git add` is run on it, even if conflict
282/// markers have been removed from the working tree.
283pub async fn conflicting_files(repo_dir: &Path) -> Vec<String> {
284    run_git(repo_dir, &["diff", "--name-only", "--diff-filter=U"])
285        .await
286        .map_or_else(|_| vec![], |output| output.lines().map(String::from).collect())
287}
288
289/// Check which files still contain conflict markers in their content.
290///
291/// Unlike [`conflicting_files`], this reads the actual working tree content
292/// rather than relying on git index state. This is the correct check after an
293/// agent edits files to resolve conflicts but before `git add` is run.
294pub async fn files_with_conflict_markers(repo_dir: &Path, files: &[String]) -> Vec<String> {
295    let mut unresolved = Vec::new();
296    for file in files {
297        let path = repo_dir.join(file);
298        if let Ok(content) = tokio::fs::read_to_string(&path).await {
299            if content.contains("<<<<<<<") || content.contains(">>>>>>>") {
300                unresolved.push(file.clone());
301            }
302        }
303    }
304    unresolved
305}
306
307/// Abort an in-progress rebase.
308pub async fn abort_rebase(repo_dir: &Path) {
309    let _ = run_git(repo_dir, &["rebase", "--abort"]).await;
310}
311
312/// Check whether a rebase is currently in progress.
313///
314/// Git creates `.git/rebase-merge` (for interactive/standard rebase) or
315/// `.git/rebase-apply` (for `git am` / `git rebase --apply`) while a rebase
316/// is active. Worktrees store these under `.git/worktrees/<name>/` instead,
317/// so we use `git rev-parse --git-dir` to find the correct location.
318pub async fn rebase_in_progress(repo_dir: &Path) -> bool {
319    let git_dir = run_git(repo_dir, &["rev-parse", "--git-dir"])
320        .await
321        .map_or_else(|_| repo_dir.join(".git"), |s| PathBuf::from(s.trim()));
322
323    let git_dir = if git_dir.is_absolute() { git_dir } else { repo_dir.join(git_dir) };
324
325    tokio::fs::try_exists(git_dir.join("rebase-merge")).await.unwrap_or(false)
326        || tokio::fs::try_exists(git_dir.join("rebase-apply")).await.unwrap_or(false)
327}
328
329/// Fetch with a single retry for transient failures (e.g. ref lock contention).
330async fn fetch_with_retry(repo_dir: &Path, branch: &str) -> Result<()> {
331    match run_git(repo_dir, &["fetch", "origin", branch]).await {
332        Ok(_) => Ok(()),
333        Err(first_err) => {
334            let msg = format!("{first_err:#}");
335            if msg.contains("unable to update local ref") || msg.contains("cannot lock ref") {
336                tracing::warn!(branch, "fetch failed with ref lock contention, retrying");
337                tokio::time::sleep(std::time::Duration::from_millis(500)).await;
338                run_git(repo_dir, &["fetch", "origin", branch])
339                    .await
340                    .map(|_| ())
341                    .with_context(|| format!("retry fetch {branch}"))
342            } else {
343                Err(first_err)
344            }
345        }
346    }
347}
348
349/// Stage resolved conflict files and continue the in-progress rebase.
350///
351/// Returns `Ok(None)` if the rebase completed successfully after continuing.
352/// Returns `Ok(Some(files))` if continuing hit new conflicts on the next commit.
353/// Returns `Err` on unexpected failures.
354///
355/// Because the parent `start_rebase` uses `--empty=drop`, any commits that
356/// become empty after conflict resolution are automatically dropped by git
357/// during `--continue`.
358pub async fn rebase_continue(
359    repo_dir: &Path,
360    conflicting: &[String],
361) -> Result<Option<Vec<String>>> {
362    for file in conflicting {
363        run_git(repo_dir, &["add", "--", file]).await.with_context(|| format!("staging {file}"))?;
364    }
365
366    let no_editor = [("GIT_EDITOR", "true")];
367    let Err(continue_err) = run_git_with_env(repo_dir, &["rebase", "--continue"], &no_editor).await
368    else {
369        return Ok(None);
370    };
371
372    // rebase --continue stopped again -- check for new conflicts on the next commit.
373    if !rebase_in_progress(repo_dir).await {
374        anyhow::bail!("rebase --continue failed and no rebase is in progress: {continue_err}");
375    }
376
377    let new_conflicts = conflicting_files(repo_dir).await;
378    if new_conflicts.is_empty() {
379        anyhow::bail!("rebase stopped after continue with no conflicts: {continue_err}");
380    }
381    Ok(Some(new_conflicts))
382}
383
384/// Get the default branch name (main or master).
385pub async fn default_branch(repo_dir: &Path) -> Result<String> {
386    // Try symbolic-ref first
387    if let Ok(output) = run_git(repo_dir, &["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
388        if let Some(branch) = output.strip_prefix("refs/remotes/origin/") {
389            return Ok(branch.to_string());
390        }
391    }
392
393    // Fallback: check if main exists, otherwise master
394    if run_git(repo_dir, &["rev-parse", "--verify", "main"]).await.is_ok() {
395        return Ok("main".to_string());
396    }
397    if run_git(repo_dir, &["rev-parse", "--verify", "master"]).await.is_ok() {
398        return Ok("master".to_string());
399    }
400
401    // Last resort: whatever HEAD points to
402    let output = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
403        .await
404        .context("detecting default branch")?;
405    Ok(output)
406}
407
408/// Get the current HEAD commit SHA.
409pub async fn head_sha(repo_dir: &Path) -> Result<String> {
410    run_git(repo_dir, &["rev-parse", "HEAD"]).await.context("getting HEAD sha")
411}
412
413/// Count commits between a ref and HEAD.
414pub async fn commit_count_since(repo_dir: &Path, since_ref: &str) -> Result<u32> {
415    let output = run_git(repo_dir, &["rev-list", "--count", &format!("{since_ref}..HEAD")])
416        .await
417        .context("counting commits since ref")?;
418    output.parse::<u32>().context("parsing commit count")
419}
420
421/// List files changed between a ref and HEAD.
422pub async fn changed_files_since(repo_dir: &Path, since_ref: &str) -> Result<Vec<String>> {
423    let output = run_git(repo_dir, &["diff", "--name-only", since_ref, "HEAD"])
424        .await
425        .context("listing changed files since ref")?;
426    Ok(output.lines().filter(|l| !l.is_empty()).map(String::from).collect())
427}
428
429async fn run_git(repo_dir: &Path, args: &[&str]) -> Result<String> {
430    run_git_with_env(repo_dir, args, &[]).await
431}
432
433async fn run_git_with_env(repo_dir: &Path, args: &[&str], env: &[(&str, &str)]) -> Result<String> {
434    let mut cmd = Command::new("git");
435    cmd.args(args).current_dir(repo_dir).kill_on_drop(true);
436    for (k, v) in env {
437        cmd.env(k, v);
438    }
439    let output = cmd.output().await.context("spawning git")?;
440
441    if !output.status.success() {
442        let stderr = String::from_utf8_lossy(&output.stderr);
443        anyhow::bail!("git {} failed: {}", args.join(" "), stderr.trim());
444    }
445
446    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    async fn init_temp_repo() -> tempfile::TempDir {
454        let dir = tempfile::tempdir().unwrap();
455
456        // Init a repo with an initial commit so we have a branch to work from
457        Command::new("git").args(["init"]).current_dir(dir.path()).output().await.unwrap();
458
459        Command::new("git")
460            .args(["config", "user.email", "test@test.com"])
461            .current_dir(dir.path())
462            .output()
463            .await
464            .unwrap();
465
466        Command::new("git")
467            .args(["config", "user.name", "Test"])
468            .current_dir(dir.path())
469            .output()
470            .await
471            .unwrap();
472
473        tokio::fs::write(dir.path().join("README.md"), "hello").await.unwrap();
474
475        Command::new("git").args(["add", "."]).current_dir(dir.path()).output().await.unwrap();
476
477        Command::new("git")
478            .args(["commit", "-m", "initial"])
479            .current_dir(dir.path())
480            .output()
481            .await
482            .unwrap();
483
484        dir
485    }
486
487    /// Create a temp repo with a bare remote so `origin/<branch>` exists.
488    /// Returns (repo dir, remote dir) -- both must be kept alive for the test.
489    async fn init_temp_repo_with_remote() -> (tempfile::TempDir, tempfile::TempDir) {
490        let dir = init_temp_repo().await;
491
492        let remote_dir = tempfile::tempdir().unwrap();
493        Command::new("git")
494            .args(["clone", "--bare", &dir.path().to_string_lossy(), "."])
495            .current_dir(remote_dir.path())
496            .output()
497            .await
498            .unwrap();
499        run_git(dir.path(), &["remote", "add", "origin", &remote_dir.path().to_string_lossy()])
500            .await
501            .unwrap();
502        run_git(dir.path(), &["fetch", "origin"]).await.unwrap();
503
504        (dir, remote_dir)
505    }
506
507    #[tokio::test]
508    async fn create_and_remove_worktree() {
509        let (dir, _remote) = init_temp_repo_with_remote().await;
510
511        // Detect the current branch name
512        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
513
514        let wt = create_worktree(dir.path(), 42, &branch).await.unwrap();
515        assert!(wt.path.exists());
516        assert!(wt.branch.starts_with("oven/issue-42-"));
517        assert_eq!(wt.issue_number, 42);
518
519        remove_worktree(dir.path(), &wt.path).await.unwrap();
520        assert!(!wt.path.exists());
521    }
522
523    #[tokio::test]
524    async fn list_worktrees_includes_created() {
525        let (dir, _remote) = init_temp_repo_with_remote().await;
526        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
527
528        let _wt = create_worktree(dir.path(), 99, &branch).await.unwrap();
529
530        let worktrees = list_worktrees(dir.path()).await.unwrap();
531        // Should have at least the main worktree + the one we created
532        assert!(worktrees.len() >= 2);
533        assert!(
534            worktrees
535                .iter()
536                .any(|w| { w.branch.as_deref().is_some_and(|b| b.starts_with("oven/issue-99-")) })
537        );
538    }
539
540    #[tokio::test]
541    async fn branch_naming_convention() {
542        let name = branch_name(123);
543        assert!(name.starts_with("oven/issue-123-"));
544        assert_eq!(name.len(), "oven/issue-123-".len() + 8);
545        // The hex part should be valid hex
546        let hex_part = &name["oven/issue-123-".len()..];
547        assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
548    }
549
550    #[tokio::test]
551    async fn default_branch_detection() {
552        let dir = init_temp_repo().await;
553        let branch = default_branch(dir.path()).await.unwrap();
554        // git init creates "main" or "master" depending on config
555        assert!(branch == "main" || branch == "master", "got: {branch}");
556    }
557
558    #[tokio::test]
559    async fn error_on_non_git_dir() {
560        let dir = tempfile::tempdir().unwrap();
561        let result = list_worktrees(dir.path()).await;
562        assert!(result.is_err());
563    }
564
565    #[tokio::test]
566    async fn force_push_branch_works() {
567        let dir = init_temp_repo().await;
568
569        // Set up a bare remote to push to
570        let remote_dir = tempfile::tempdir().unwrap();
571        Command::new("git")
572            .args(["clone", "--bare", &dir.path().to_string_lossy(), "."])
573            .current_dir(remote_dir.path())
574            .output()
575            .await
576            .unwrap();
577
578        run_git(dir.path(), &["remote", "add", "origin", &remote_dir.path().to_string_lossy()])
579            .await
580            .unwrap();
581
582        // Create a branch, push it, then amend and force-push
583        run_git(dir.path(), &["checkout", "-b", "test-branch"]).await.unwrap();
584        tokio::fs::write(dir.path().join("new.txt"), "v1").await.unwrap();
585        run_git(dir.path(), &["add", "."]).await.unwrap();
586        run_git(dir.path(), &["commit", "-m", "v1"]).await.unwrap();
587        push_branch(dir.path(), "test-branch").await.unwrap();
588
589        // Amend the commit (simulating a rebase)
590        tokio::fs::write(dir.path().join("new.txt"), "v2").await.unwrap();
591        run_git(dir.path(), &["add", "."]).await.unwrap();
592        run_git(dir.path(), &["commit", "--amend", "-m", "v2"]).await.unwrap();
593
594        // Regular push should fail, force push should succeed
595        assert!(push_branch(dir.path(), "test-branch").await.is_err());
596        assert!(force_push_branch(dir.path(), "test-branch").await.is_ok());
597    }
598
599    #[tokio::test]
600    async fn start_rebase_clean() {
601        let dir = init_temp_repo().await;
602        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
603
604        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
605        tokio::fs::write(dir.path().join("feature.txt"), "feature work").await.unwrap();
606        run_git(dir.path(), &["add", "."]).await.unwrap();
607        run_git(dir.path(), &["commit", "-m", "feature commit"]).await.unwrap();
608
609        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
610        tokio::fs::write(dir.path().join("base.txt"), "base work").await.unwrap();
611        run_git(dir.path(), &["add", "."]).await.unwrap();
612        run_git(dir.path(), &["commit", "-m", "base commit"]).await.unwrap();
613
614        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
615        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
616            .await
617            .unwrap();
618
619        let outcome = start_rebase(dir.path(), &branch).await;
620        assert!(matches!(outcome, RebaseOutcome::Clean));
621        assert!(dir.path().join("feature.txt").exists());
622        assert!(dir.path().join("base.txt").exists());
623    }
624
625    #[tokio::test]
626    async fn start_rebase_conflicts() {
627        let dir = init_temp_repo().await;
628        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
629
630        // Create a feature branch that modifies README.md
631        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
632        tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
633        run_git(dir.path(), &["add", "."]).await.unwrap();
634        run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
635
636        // Create a conflicting change on the base branch
637        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
638        tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
639        run_git(dir.path(), &["add", "."]).await.unwrap();
640        run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
641
642        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
643        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
644            .await
645            .unwrap();
646
647        let outcome = start_rebase(dir.path(), &branch).await;
648        assert!(
649            matches!(outcome, RebaseOutcome::RebaseConflicts(_)),
650            "expected RebaseConflicts, got {outcome:?}"
651        );
652
653        // Tree should be in mid-rebase state
654        assert!(
655            dir.path().join(".git/rebase-merge").exists()
656                || dir.path().join(".git/rebase-apply").exists()
657        );
658
659        // Clean up
660        abort_rebase(dir.path()).await;
661    }
662
663    #[tokio::test]
664    async fn start_rebase_no_remote_fails() {
665        let dir = init_temp_repo().await;
666        // No remote configured, so fetch will fail
667        let outcome = start_rebase(dir.path(), "main").await;
668        assert!(matches!(outcome, RebaseOutcome::Failed(_)));
669    }
670
671    #[tokio::test]
672    async fn rebase_continue_resolves_conflict() {
673        let dir = init_temp_repo().await;
674        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
675
676        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
677        tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
678        run_git(dir.path(), &["add", "."]).await.unwrap();
679        run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
680
681        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
682        tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
683        run_git(dir.path(), &["add", "."]).await.unwrap();
684        run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
685
686        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
687        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
688            .await
689            .unwrap();
690
691        let outcome = start_rebase(dir.path(), &branch).await;
692        let files = match outcome {
693            RebaseOutcome::RebaseConflicts(f) => f,
694            other => panic!("expected RebaseConflicts, got {other:?}"),
695        };
696
697        // Manually resolve the conflict
698        tokio::fs::write(dir.path().join("README.md"), "resolved version").await.unwrap();
699
700        let result = rebase_continue(dir.path(), &files).await.unwrap();
701        assert!(result.is_none(), "expected rebase to complete, got more conflicts");
702
703        // Verify rebase completed (no rebase-merge dir)
704        assert!(!dir.path().join(".git/rebase-merge").exists());
705        assert!(!dir.path().join(".git/rebase-apply").exists());
706    }
707
708    #[tokio::test]
709    async fn start_rebase_skips_empty_commit() {
710        let dir = init_temp_repo().await;
711        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
712
713        // Create a feature branch with a change
714        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
715        tokio::fs::write(dir.path().join("README.md"), "changed").await.unwrap();
716        run_git(dir.path(), &["add", "."]).await.unwrap();
717        run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
718
719        // Cherry-pick the same change onto base so the feature commit becomes empty
720        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
721        tokio::fs::write(dir.path().join("README.md"), "changed").await.unwrap();
722        run_git(dir.path(), &["add", "."]).await.unwrap();
723        run_git(dir.path(), &["commit", "-m", "same change on base"]).await.unwrap();
724
725        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
726        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
727            .await
728            .unwrap();
729
730        // Rebase should skip the empty commit and succeed
731        let outcome = start_rebase(dir.path(), &branch).await;
732        assert!(
733            matches!(outcome, RebaseOutcome::Clean),
734            "expected Clean after skipping empty commit, got {outcome:?}"
735        );
736
737        // Verify rebase completed
738        assert!(!dir.path().join(".git/rebase-merge").exists());
739        assert!(!dir.path().join(".git/rebase-apply").exists());
740    }
741
742    #[tokio::test]
743    async fn rebase_continue_skips_empty_commit_after_real_conflict() {
744        let dir = init_temp_repo().await;
745        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
746
747        // Feature branch: commit 1 changes README, commit 2 changes other.txt
748        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
749        tokio::fs::write(dir.path().join("README.md"), "feature readme").await.unwrap();
750        run_git(dir.path(), &["add", "."]).await.unwrap();
751        run_git(dir.path(), &["commit", "-m", "feature readme"]).await.unwrap();
752
753        tokio::fs::write(dir.path().join("other.txt"), "feature other").await.unwrap();
754        run_git(dir.path(), &["add", "."]).await.unwrap();
755        run_git(dir.path(), &["commit", "-m", "feature other"]).await.unwrap();
756
757        // Base branch: conflict on README, cherry-pick the same other.txt change
758        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
759        tokio::fs::write(dir.path().join("README.md"), "base readme").await.unwrap();
760        run_git(dir.path(), &["add", "."]).await.unwrap();
761        run_git(dir.path(), &["commit", "-m", "base readme"]).await.unwrap();
762
763        tokio::fs::write(dir.path().join("other.txt"), "feature other").await.unwrap();
764        run_git(dir.path(), &["add", "."]).await.unwrap();
765        run_git(dir.path(), &["commit", "-m", "same other on base"]).await.unwrap();
766
767        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
768        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
769            .await
770            .unwrap();
771
772        // First commit (README) will have a real conflict
773        let outcome = start_rebase(dir.path(), &branch).await;
774        let files = match outcome {
775            RebaseOutcome::RebaseConflicts(f) => f,
776            other => panic!("expected RebaseConflicts, got {other:?}"),
777        };
778        assert!(files.contains(&"README.md".to_string()));
779
780        // Resolve the conflict manually
781        tokio::fs::write(dir.path().join("README.md"), "resolved").await.unwrap();
782
783        // Continue should resolve the conflict, then skip the empty second commit
784        let result = rebase_continue(dir.path(), &files).await.unwrap();
785        assert!(result.is_none(), "expected rebase to complete, got more conflicts");
786
787        assert!(!dir.path().join(".git/rebase-merge").exists());
788        assert!(!dir.path().join(".git/rebase-apply").exists());
789    }
790
791    #[tokio::test]
792    async fn conflict_markers_detected_in_content() {
793        let dir = tempfile::tempdir().unwrap();
794        let with_markers = "line 1\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nline 2";
795        let without_markers = "line 1\nresolved content\nline 2";
796
797        tokio::fs::write(dir.path().join("conflicted.txt"), with_markers).await.unwrap();
798        tokio::fs::write(dir.path().join("resolved.txt"), without_markers).await.unwrap();
799
800        let files = vec!["conflicted.txt".to_string(), "resolved.txt".to_string()];
801        let result = files_with_conflict_markers(dir.path(), &files).await;
802
803        assert_eq!(result, vec!["conflicted.txt"]);
804    }
805
806    #[tokio::test]
807    async fn conflict_markers_empty_when_all_resolved() {
808        let dir = tempfile::tempdir().unwrap();
809        tokio::fs::write(dir.path().join("a.txt"), "clean content").await.unwrap();
810        tokio::fs::write(dir.path().join("b.txt"), "also clean").await.unwrap();
811
812        let files = vec!["a.txt".to_string(), "b.txt".to_string()];
813        let result = files_with_conflict_markers(dir.path(), &files).await;
814
815        assert!(result.is_empty());
816    }
817
818    #[tokio::test]
819    async fn conflict_markers_missing_file_skipped() {
820        let dir = tempfile::tempdir().unwrap();
821        let files = vec!["nonexistent.txt".to_string()];
822        let result = files_with_conflict_markers(dir.path(), &files).await;
823
824        assert!(result.is_empty());
825    }
826
827    #[tokio::test]
828    async fn resolved_file_still_unmerged_in_index() {
829        // Reproduces the root cause: agent resolves conflict markers in the
830        // working tree, but git index still shows the file as Unmerged because
831        // `git add` hasn't been run yet.
832        let dir = init_temp_repo().await;
833        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
834
835        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
836        tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
837        run_git(dir.path(), &["add", "."]).await.unwrap();
838        run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
839
840        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
841        tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
842        run_git(dir.path(), &["add", "."]).await.unwrap();
843        run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
844
845        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
846        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
847            .await
848            .unwrap();
849
850        let outcome = start_rebase(dir.path(), &branch).await;
851        let files = match outcome {
852            RebaseOutcome::RebaseConflicts(f) => f,
853            other => panic!("expected RebaseConflicts, got {other:?}"),
854        };
855
856        // Resolve the conflict markers in the working tree (simulating what an agent does)
857        tokio::fs::write(dir.path().join("README.md"), "resolved version").await.unwrap();
858
859        // Index-based check still sees the file as Unmerged (the old broken check)
860        let index_conflicts = conflicting_files(dir.path()).await;
861        assert!(
862            !index_conflicts.is_empty(),
863            "file should still be Unmerged in git index before git add"
864        );
865
866        // Content-based check correctly sees no conflict markers
867        let content_conflicts = files_with_conflict_markers(dir.path(), &files).await;
868        assert!(
869            content_conflicts.is_empty(),
870            "file content has no conflict markers, should be empty"
871        );
872
873        // Clean up
874        abort_rebase(dir.path()).await;
875    }
876
877    #[tokio::test]
878    async fn is_dirty_detects_modified_files() {
879        let dir = init_temp_repo().await;
880        assert!(!is_dirty(dir.path()).await.unwrap());
881
882        tokio::fs::write(dir.path().join("README.md"), "modified").await.unwrap();
883        assert!(is_dirty(dir.path()).await.unwrap());
884    }
885
886    #[tokio::test]
887    async fn commit_all_commits_tracked_changes_only() {
888        let dir = init_temp_repo().await;
889
890        // Untracked file should NOT be staged (git add -u ignores untracked)
891        tokio::fs::write(dir.path().join("new.txt"), "new file").await.unwrap();
892        // Modified tracked file should be staged
893        tokio::fs::write(dir.path().join("README.md"), "modified").await.unwrap();
894
895        let committed = commit_all(dir.path(), "save agent work").await.unwrap();
896        assert!(committed);
897
898        let log = run_git(dir.path(), &["log", "--oneline", "-1"]).await.unwrap();
899        assert!(log.contains("save agent work"));
900
901        // README.md should be committed, new.txt should still be untracked
902        let diff = run_git(dir.path(), &["diff", "HEAD~1", "--name-only"]).await.unwrap();
903        assert!(diff.contains("README.md"));
904        assert!(!diff.contains("new.txt"));
905
906        // Worktree still dirty because of the untracked file
907        assert!(is_dirty(dir.path()).await.unwrap());
908    }
909
910    #[tokio::test]
911    async fn commit_all_returns_false_when_clean() {
912        let dir = init_temp_repo().await;
913        let committed = commit_all(dir.path(), "nothing to commit").await.unwrap();
914        assert!(!committed);
915    }
916
917    #[tokio::test]
918    async fn commit_all_skips_untracked_only_worktree() {
919        let dir = init_temp_repo().await;
920
921        // Only untracked files -- is_dirty sees them but commit_all should skip
922        tokio::fs::write(dir.path().join("temp.log"), "agent output").await.unwrap();
923        assert!(is_dirty(dir.path()).await.unwrap());
924
925        let committed = commit_all(dir.path(), "should not commit").await.unwrap();
926        assert!(!committed);
927
928        // The untracked file should still be there, not committed
929        let log = run_git(dir.path(), &["log", "--oneline", "-1"]).await.unwrap();
930        assert!(!log.contains("should not commit"));
931    }
932
933    #[tokio::test]
934    async fn start_rebase_dirty_worktree_returns_failed() {
935        let dir = init_temp_repo().await;
936        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
937
938        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
939        tokio::fs::write(dir.path().join("feature.txt"), "feature work").await.unwrap();
940        run_git(dir.path(), &["add", "."]).await.unwrap();
941        run_git(dir.path(), &["commit", "-m", "feature commit"]).await.unwrap();
942
943        // Leave uncommitted changes to a tracked file (simulating fixer that didn't commit)
944        tokio::fs::write(dir.path().join("README.md"), "uncommitted work").await.unwrap();
945
946        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
947            .await
948            .unwrap();
949
950        let outcome = start_rebase(dir.path(), &branch).await;
951        assert!(
952            matches!(outcome, RebaseOutcome::Failed(ref msg) if msg.contains("could not start")),
953            "expected Failed with 'could not start' message, got {outcome:?}"
954        );
955    }
956
957    #[tokio::test]
958    async fn rebase_in_progress_false_when_clean() {
959        let dir = init_temp_repo().await;
960        assert!(!rebase_in_progress(dir.path()).await);
961    }
962
963    #[tokio::test]
964    async fn rebase_in_progress_true_during_conflict() {
965        let dir = init_temp_repo().await;
966        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
967
968        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
969        tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
970        run_git(dir.path(), &["add", "."]).await.unwrap();
971        run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
972
973        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
974        tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
975        run_git(dir.path(), &["add", "."]).await.unwrap();
976        run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
977
978        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
979        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
980            .await
981            .unwrap();
982
983        let outcome = start_rebase(dir.path(), &branch).await;
984        assert!(matches!(outcome, RebaseOutcome::RebaseConflicts(_)));
985        assert!(rebase_in_progress(dir.path()).await);
986
987        abort_rebase(dir.path()).await;
988        assert!(!rebase_in_progress(dir.path()).await);
989    }
990
991    #[tokio::test]
992    async fn fetch_branch_updates_remote_tracking_ref() {
993        let (dir, remote_dir) = init_temp_repo_with_remote().await;
994        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
995
996        let before_sha =
997            run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
998
999        let _other = push_remote_commit(remote_dir.path(), &branch, "remote.txt").await;
1000
1001        // Remote tracking ref should still be at the old SHA
1002        let stale_sha =
1003            run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
1004        assert_eq!(before_sha, stale_sha);
1005
1006        // fetch_branch should update the remote tracking ref
1007        fetch_branch(dir.path(), &branch).await.unwrap();
1008
1009        let after_sha =
1010            run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
1011        assert_ne!(before_sha, after_sha, "origin/{branch} should have advanced after fetch");
1012    }
1013
1014    #[tokio::test]
1015    async fn fetch_branch_no_remote_errors() {
1016        let dir = init_temp_repo().await;
1017        let result = fetch_branch(dir.path(), "main").await;
1018        assert!(result.is_err());
1019    }
1020
1021    #[tokio::test]
1022    async fn worktree_after_fetch_includes_remote_changes() {
1023        let (dir, remote_dir) = init_temp_repo_with_remote().await;
1024        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
1025
1026        let _other = push_remote_commit(remote_dir.path(), &branch, "merged.txt").await;
1027
1028        fetch_branch(dir.path(), &branch).await.unwrap();
1029
1030        let wt = create_worktree(dir.path(), 99, &branch).await.unwrap();
1031        assert!(
1032            wt.path.join("merged.txt").exists(),
1033            "worktree should contain the file from the merged PR"
1034        );
1035    }
1036
1037    /// Helper: push a commit from a separate clone so the local repo falls behind.
1038    async fn push_remote_commit(
1039        remote_dir: &std::path::Path,
1040        branch: &str,
1041        filename: &str,
1042    ) -> tempfile::TempDir {
1043        let other = tempfile::tempdir().unwrap();
1044        Command::new("git")
1045            .args(["clone", &remote_dir.to_string_lossy(), "."])
1046            .current_dir(other.path())
1047            .output()
1048            .await
1049            .unwrap();
1050        for args in
1051            [&["config", "user.email", "test@test.com"][..], &["config", "user.name", "Test"]]
1052        {
1053            Command::new("git").args(args).current_dir(other.path()).output().await.unwrap();
1054        }
1055        tokio::fs::write(other.path().join(filename), "content").await.unwrap();
1056        run_git(other.path(), &["add", "."]).await.unwrap();
1057        run_git(other.path(), &["commit", "-m", &format!("add {filename}")]).await.unwrap();
1058        run_git(other.path(), &["push", "origin", branch]).await.unwrap();
1059        other
1060    }
1061
1062    #[tokio::test]
1063    async fn advance_local_branch_when_checked_out() {
1064        let (dir, remote_dir) = init_temp_repo_with_remote().await;
1065        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
1066
1067        let before = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
1068
1069        let _other = push_remote_commit(remote_dir.path(), &branch, "new.txt").await;
1070        fetch_branch(dir.path(), &branch).await.unwrap();
1071
1072        // Local branch should still be behind after fetch
1073        let after_fetch = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
1074        assert_eq!(before, after_fetch, "local branch should not advance from fetch alone");
1075
1076        // advance_local_branch should fast-forward it
1077        advance_local_branch(dir.path(), &branch).await.unwrap();
1078
1079        let after_advance = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
1080        let remote_sha =
1081            run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
1082        assert_eq!(after_advance, remote_sha, "local branch should match origin after advance");
1083        assert!(dir.path().join("new.txt").exists(), "working tree should have the new file");
1084    }
1085
1086    #[tokio::test]
1087    async fn advance_local_branch_when_not_checked_out() {
1088        let (dir, remote_dir) = init_temp_repo_with_remote().await;
1089        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
1090
1091        // Switch to a different branch so the base branch is not checked out
1092        run_git(dir.path(), &["checkout", "-b", "other"]).await.unwrap();
1093
1094        let before = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
1095
1096        let _other = push_remote_commit(remote_dir.path(), &branch, "new.txt").await;
1097        fetch_branch(dir.path(), &branch).await.unwrap();
1098
1099        advance_local_branch(dir.path(), &branch).await.unwrap();
1100
1101        let after = run_git(dir.path(), &["rev-parse", &branch]).await.unwrap();
1102        let remote_sha =
1103            run_git(dir.path(), &["rev-parse", &format!("origin/{branch}")]).await.unwrap();
1104        assert_ne!(before, after, "local branch should have moved");
1105        assert_eq!(after, remote_sha, "local branch should match origin after advance");
1106    }
1107}