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 `base_branch`.
28pub async fn create_worktree(
29    repo_dir: &Path,
30    issue_number: u32,
31    base_branch: &str,
32) -> Result<Worktree> {
33    let branch = branch_name(issue_number);
34    let worktree_path =
35        repo_dir.join(".oven").join("worktrees").join(format!("issue-{issue_number}"));
36
37    // Ensure parent directory exists
38    if let Some(parent) = worktree_path.parent() {
39        tokio::fs::create_dir_all(parent).await.context("creating worktree parent directory")?;
40    }
41
42    run_git(
43        repo_dir,
44        &["worktree", "add", "-b", &branch, &worktree_path.to_string_lossy(), base_branch],
45    )
46    .await
47    .context("creating worktree")?;
48
49    Ok(Worktree { path: worktree_path, branch, issue_number })
50}
51
52/// Remove a worktree by path.
53pub async fn remove_worktree(repo_dir: &Path, worktree_path: &Path) -> Result<()> {
54    run_git(repo_dir, &["worktree", "remove", "--force", &worktree_path.to_string_lossy()])
55        .await
56        .context("removing worktree")?;
57    Ok(())
58}
59
60/// List all worktrees in the repository.
61pub async fn list_worktrees(repo_dir: &Path) -> Result<Vec<WorktreeInfo>> {
62    let output = run_git(repo_dir, &["worktree", "list", "--porcelain"])
63        .await
64        .context("listing worktrees")?;
65
66    let mut worktrees = Vec::new();
67    let mut current_path: Option<PathBuf> = None;
68    let mut current_branch: Option<String> = None;
69
70    for line in output.lines() {
71        if let Some(path_str) = line.strip_prefix("worktree ") {
72            // Save previous worktree if we have one
73            if let Some(path) = current_path.take() {
74                worktrees.push(WorktreeInfo { path, branch: current_branch.take() });
75            }
76            current_path = Some(PathBuf::from(path_str));
77        } else if let Some(branch_ref) = line.strip_prefix("branch ") {
78            // Extract branch name from refs/heads/...
79            current_branch =
80                Some(branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref).to_string());
81        }
82    }
83
84    // Don't forget the last one
85    if let Some(path) = current_path {
86        worktrees.push(WorktreeInfo { path, branch: current_branch });
87    }
88
89    Ok(worktrees)
90}
91
92/// Prune stale worktrees and return the count pruned.
93pub async fn clean_worktrees(repo_dir: &Path) -> Result<u32> {
94    let before = list_worktrees(repo_dir).await?;
95    run_git(repo_dir, &["worktree", "prune"]).await.context("pruning worktrees")?;
96    let after = list_worktrees(repo_dir).await?;
97
98    let pruned = if before.len() > after.len() { before.len() - after.len() } else { 0 };
99    Ok(u32::try_from(pruned).unwrap_or(u32::MAX))
100}
101
102/// Delete a local branch.
103pub async fn delete_branch(repo_dir: &Path, branch: &str) -> Result<()> {
104    run_git(repo_dir, &["branch", "-D", branch]).await.context("deleting branch")?;
105    Ok(())
106}
107
108/// List merged branches matching `oven/*`.
109pub async fn list_merged_branches(repo_dir: &Path, base: &str) -> Result<Vec<String>> {
110    let output = run_git(repo_dir, &["branch", "--merged", base])
111        .await
112        .context("listing merged branches")?;
113
114    let branches = output
115        .lines()
116        .map(|l| l.trim().trim_start_matches("* ").to_string())
117        .filter(|b| b.starts_with("oven/"))
118        .collect();
119
120    Ok(branches)
121}
122
123/// Create an empty commit (used to seed a branch before PR creation).
124pub async fn empty_commit(repo_dir: &Path, message: &str) -> Result<()> {
125    run_git(repo_dir, &["commit", "--allow-empty", "-m", message])
126        .await
127        .context("creating empty commit")?;
128    Ok(())
129}
130
131/// Push a branch to origin.
132pub async fn push_branch(repo_dir: &Path, branch: &str) -> Result<()> {
133    run_git(repo_dir, &["push", "origin", branch]).await.context("pushing branch")?;
134    Ok(())
135}
136
137/// Force-push a branch to origin using `--force-with-lease` for safety.
138///
139/// Used after rebasing a pipeline branch onto the updated base branch.
140pub async fn force_push_branch(repo_dir: &Path, branch: &str) -> Result<()> {
141    let lease = format!("--force-with-lease=refs/heads/{branch}");
142    run_git(repo_dir, &["push", &lease, "origin", branch]).await.context("force-pushing branch")?;
143    Ok(())
144}
145
146/// Outcome of a rebase attempt.
147#[derive(Debug)]
148pub enum RebaseOutcome {
149    /// Rebase succeeded cleanly.
150    Clean,
151    /// Rebase had conflicts. The working tree is left in a mid-rebase state
152    /// so the caller can attempt agent-assisted resolution via
153    /// [`rebase_continue`] / [`abort_rebase`].
154    RebaseConflicts(Vec<String>),
155    /// Agent resolved the rebase conflicts.
156    AgentResolved,
157    /// Unrecoverable failure (e.g. fetch failed).
158    Failed(String),
159}
160
161/// Start a rebase of the current branch onto the latest `origin/<base_branch>`.
162///
163/// If the rebase succeeds cleanly, returns `Clean`. If it hits conflicts, the
164/// working tree is left in a mid-rebase state and `RebaseConflicts` is returned
165/// with the list of conflicting files. The caller should resolve them and call
166/// [`rebase_continue`], or [`abort_rebase`] to give up.
167pub async fn start_rebase(repo_dir: &Path, base_branch: &str) -> RebaseOutcome {
168    if let Err(e) = run_git(repo_dir, &["fetch", "origin", base_branch]).await {
169        return RebaseOutcome::Failed(format!("failed to fetch {base_branch}: {e}"));
170    }
171
172    let target = format!("origin/{base_branch}");
173
174    let no_editor = [("GIT_EDITOR", "true")];
175    if run_git_with_env(repo_dir, &["rebase", &target], &no_editor).await.is_ok() {
176        return RebaseOutcome::Clean;
177    }
178
179    // Rebase stopped -- check if it's real conflicts or an empty commit.
180    let conflicting = conflicting_files(repo_dir).await;
181    if conflicting.is_empty() {
182        // No conflicting files means an empty commit (patch already applied).
183        // Skip it rather than sending an agent to resolve nothing.
184        match skip_empty_rebase_commits(repo_dir).await {
185            Ok(None) => return RebaseOutcome::Clean,
186            Ok(Some(files)) => return RebaseOutcome::RebaseConflicts(files),
187            Err(e) => return RebaseOutcome::Failed(format!("{e:#}")),
188        }
189    }
190    RebaseOutcome::RebaseConflicts(conflicting)
191}
192
193/// List files with unresolved merge conflicts.
194pub async fn conflicting_files(repo_dir: &Path) -> Vec<String> {
195    run_git(repo_dir, &["diff", "--name-only", "--diff-filter=U"])
196        .await
197        .map_or_else(|_| vec![], |output| output.lines().map(String::from).collect())
198}
199
200/// Abort an in-progress rebase.
201pub async fn abort_rebase(repo_dir: &Path) {
202    let _ = run_git(repo_dir, &["rebase", "--abort"]).await;
203}
204
205/// Skip empty commits during a rebase when no conflicting files are present.
206///
207/// During rebase replay, a commit can become empty if its changes are already
208/// present on the target branch. Git stops on these by default. This function
209/// runs `git rebase --skip` in a loop until the rebase completes or real
210/// conflicts appear.
211///
212/// Returns `Ok(None)` if the rebase completed after skipping.
213/// Returns `Ok(Some(files))` if real conflicts appeared after a skip.
214/// Returns `Err` if the maximum number of skips was exhausted.
215async fn skip_empty_rebase_commits(repo_dir: &Path) -> Result<Option<Vec<String>>> {
216    const MAX_SKIPS: u32 = 10;
217    let no_editor = [("GIT_EDITOR", "true")];
218
219    for _ in 0..MAX_SKIPS {
220        if run_git_with_env(repo_dir, &["rebase", "--skip"], &no_editor).await.is_ok() {
221            return Ok(None);
222        }
223
224        // Skip stopped again -- check for real conflicts vs another empty commit.
225        let conflicts = conflicting_files(repo_dir).await;
226        if !conflicts.is_empty() {
227            return Ok(Some(conflicts));
228        }
229    }
230
231    abort_rebase(repo_dir).await;
232    anyhow::bail!("rebase had too many empty commits (skipped {MAX_SKIPS} times)")
233}
234
235/// Stage resolved conflict files and continue the in-progress rebase.
236///
237/// Returns `Ok(None)` if the rebase completed successfully after continuing.
238/// Returns `Ok(Some(files))` if continuing hit new conflicts on the next commit.
239/// Returns `Err` on unexpected failures.
240pub async fn rebase_continue(
241    repo_dir: &Path,
242    conflicting: &[String],
243) -> Result<Option<Vec<String>>> {
244    for file in conflicting {
245        run_git(repo_dir, &["add", "--", file]).await.with_context(|| format!("staging {file}"))?;
246    }
247
248    let no_editor = [("GIT_EDITOR", "true")];
249    if run_git_with_env(repo_dir, &["rebase", "--continue"], &no_editor).await.is_ok() {
250        return Ok(None);
251    }
252
253    // rebase --continue stopped again -- new conflicts on the next commit.
254    let new_conflicts = conflicting_files(repo_dir).await;
255    if new_conflicts.is_empty() {
256        // Empty commit after continue -- skip it.
257        return skip_empty_rebase_commits(repo_dir).await;
258    }
259    Ok(Some(new_conflicts))
260}
261
262/// Get the default branch name (main or master).
263pub async fn default_branch(repo_dir: &Path) -> Result<String> {
264    // Try symbolic-ref first
265    if let Ok(output) = run_git(repo_dir, &["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
266        if let Some(branch) = output.strip_prefix("refs/remotes/origin/") {
267            return Ok(branch.to_string());
268        }
269    }
270
271    // Fallback: check if main exists, otherwise master
272    if run_git(repo_dir, &["rev-parse", "--verify", "main"]).await.is_ok() {
273        return Ok("main".to_string());
274    }
275    if run_git(repo_dir, &["rev-parse", "--verify", "master"]).await.is_ok() {
276        return Ok("master".to_string());
277    }
278
279    // Last resort: whatever HEAD points to
280    let output = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
281        .await
282        .context("detecting default branch")?;
283    Ok(output)
284}
285
286/// Get the current HEAD commit SHA.
287pub async fn head_sha(repo_dir: &Path) -> Result<String> {
288    run_git(repo_dir, &["rev-parse", "HEAD"]).await.context("getting HEAD sha")
289}
290
291/// Count commits between a ref and HEAD.
292pub async fn commit_count_since(repo_dir: &Path, since_ref: &str) -> Result<u32> {
293    let output = run_git(repo_dir, &["rev-list", "--count", &format!("{since_ref}..HEAD")])
294        .await
295        .context("counting commits since ref")?;
296    output.parse::<u32>().context("parsing commit count")
297}
298
299/// List files changed between a ref and HEAD.
300pub async fn changed_files_since(repo_dir: &Path, since_ref: &str) -> Result<Vec<String>> {
301    let output = run_git(repo_dir, &["diff", "--name-only", since_ref, "HEAD"])
302        .await
303        .context("listing changed files since ref")?;
304    Ok(output.lines().filter(|l| !l.is_empty()).map(String::from).collect())
305}
306
307async fn run_git(repo_dir: &Path, args: &[&str]) -> Result<String> {
308    run_git_with_env(repo_dir, args, &[]).await
309}
310
311async fn run_git_with_env(repo_dir: &Path, args: &[&str], env: &[(&str, &str)]) -> Result<String> {
312    let mut cmd = Command::new("git");
313    cmd.args(args).current_dir(repo_dir).kill_on_drop(true);
314    for (k, v) in env {
315        cmd.env(k, v);
316    }
317    let output = cmd.output().await.context("spawning git")?;
318
319    if !output.status.success() {
320        let stderr = String::from_utf8_lossy(&output.stderr);
321        anyhow::bail!("git {} failed: {}", args.join(" "), stderr.trim());
322    }
323
324    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    async fn init_temp_repo() -> tempfile::TempDir {
332        let dir = tempfile::tempdir().unwrap();
333
334        // Init a repo with an initial commit so we have a branch to work from
335        Command::new("git").args(["init"]).current_dir(dir.path()).output().await.unwrap();
336
337        Command::new("git")
338            .args(["config", "user.email", "test@test.com"])
339            .current_dir(dir.path())
340            .output()
341            .await
342            .unwrap();
343
344        Command::new("git")
345            .args(["config", "user.name", "Test"])
346            .current_dir(dir.path())
347            .output()
348            .await
349            .unwrap();
350
351        tokio::fs::write(dir.path().join("README.md"), "hello").await.unwrap();
352
353        Command::new("git").args(["add", "."]).current_dir(dir.path()).output().await.unwrap();
354
355        Command::new("git")
356            .args(["commit", "-m", "initial"])
357            .current_dir(dir.path())
358            .output()
359            .await
360            .unwrap();
361
362        dir
363    }
364
365    #[tokio::test]
366    async fn create_and_remove_worktree() {
367        let dir = init_temp_repo().await;
368
369        // Detect the current branch name
370        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
371
372        let wt = create_worktree(dir.path(), 42, &branch).await.unwrap();
373        assert!(wt.path.exists());
374        assert!(wt.branch.starts_with("oven/issue-42-"));
375        assert_eq!(wt.issue_number, 42);
376
377        remove_worktree(dir.path(), &wt.path).await.unwrap();
378        assert!(!wt.path.exists());
379    }
380
381    #[tokio::test]
382    async fn list_worktrees_includes_created() {
383        let dir = init_temp_repo().await;
384        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
385
386        let _wt = create_worktree(dir.path(), 99, &branch).await.unwrap();
387
388        let worktrees = list_worktrees(dir.path()).await.unwrap();
389        // Should have at least the main worktree + the one we created
390        assert!(worktrees.len() >= 2);
391        assert!(
392            worktrees
393                .iter()
394                .any(|w| { w.branch.as_deref().is_some_and(|b| b.starts_with("oven/issue-99-")) })
395        );
396    }
397
398    #[tokio::test]
399    async fn branch_naming_convention() {
400        let name = branch_name(123);
401        assert!(name.starts_with("oven/issue-123-"));
402        assert_eq!(name.len(), "oven/issue-123-".len() + 8);
403        // The hex part should be valid hex
404        let hex_part = &name["oven/issue-123-".len()..];
405        assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
406    }
407
408    #[tokio::test]
409    async fn default_branch_detection() {
410        let dir = init_temp_repo().await;
411        let branch = default_branch(dir.path()).await.unwrap();
412        // git init creates "main" or "master" depending on config
413        assert!(branch == "main" || branch == "master", "got: {branch}");
414    }
415
416    #[tokio::test]
417    async fn error_on_non_git_dir() {
418        let dir = tempfile::tempdir().unwrap();
419        let result = list_worktrees(dir.path()).await;
420        assert!(result.is_err());
421    }
422
423    #[tokio::test]
424    async fn force_push_branch_works() {
425        let dir = init_temp_repo().await;
426
427        // Set up a bare remote to push to
428        let remote_dir = tempfile::tempdir().unwrap();
429        Command::new("git")
430            .args(["clone", "--bare", &dir.path().to_string_lossy(), "."])
431            .current_dir(remote_dir.path())
432            .output()
433            .await
434            .unwrap();
435
436        run_git(dir.path(), &["remote", "add", "origin", &remote_dir.path().to_string_lossy()])
437            .await
438            .unwrap();
439
440        // Create a branch, push it, then amend and force-push
441        run_git(dir.path(), &["checkout", "-b", "test-branch"]).await.unwrap();
442        tokio::fs::write(dir.path().join("new.txt"), "v1").await.unwrap();
443        run_git(dir.path(), &["add", "."]).await.unwrap();
444        run_git(dir.path(), &["commit", "-m", "v1"]).await.unwrap();
445        push_branch(dir.path(), "test-branch").await.unwrap();
446
447        // Amend the commit (simulating a rebase)
448        tokio::fs::write(dir.path().join("new.txt"), "v2").await.unwrap();
449        run_git(dir.path(), &["add", "."]).await.unwrap();
450        run_git(dir.path(), &["commit", "--amend", "-m", "v2"]).await.unwrap();
451
452        // Regular push should fail, force push should succeed
453        assert!(push_branch(dir.path(), "test-branch").await.is_err());
454        assert!(force_push_branch(dir.path(), "test-branch").await.is_ok());
455    }
456
457    #[tokio::test]
458    async fn start_rebase_clean() {
459        let dir = init_temp_repo().await;
460        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
461
462        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
463        tokio::fs::write(dir.path().join("feature.txt"), "feature work").await.unwrap();
464        run_git(dir.path(), &["add", "."]).await.unwrap();
465        run_git(dir.path(), &["commit", "-m", "feature commit"]).await.unwrap();
466
467        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
468        tokio::fs::write(dir.path().join("base.txt"), "base work").await.unwrap();
469        run_git(dir.path(), &["add", "."]).await.unwrap();
470        run_git(dir.path(), &["commit", "-m", "base commit"]).await.unwrap();
471
472        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
473        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
474            .await
475            .unwrap();
476
477        let outcome = start_rebase(dir.path(), &branch).await;
478        assert!(matches!(outcome, RebaseOutcome::Clean));
479        assert!(dir.path().join("feature.txt").exists());
480        assert!(dir.path().join("base.txt").exists());
481    }
482
483    #[tokio::test]
484    async fn start_rebase_conflicts() {
485        let dir = init_temp_repo().await;
486        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
487
488        // Create a feature branch that modifies README.md
489        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
490        tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
491        run_git(dir.path(), &["add", "."]).await.unwrap();
492        run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
493
494        // Create a conflicting change on the base branch
495        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
496        tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
497        run_git(dir.path(), &["add", "."]).await.unwrap();
498        run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
499
500        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
501        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
502            .await
503            .unwrap();
504
505        let outcome = start_rebase(dir.path(), &branch).await;
506        assert!(
507            matches!(outcome, RebaseOutcome::RebaseConflicts(_)),
508            "expected RebaseConflicts, got {outcome:?}"
509        );
510
511        // Tree should be in mid-rebase state
512        assert!(
513            dir.path().join(".git/rebase-merge").exists()
514                || dir.path().join(".git/rebase-apply").exists()
515        );
516
517        // Clean up
518        abort_rebase(dir.path()).await;
519    }
520
521    #[tokio::test]
522    async fn start_rebase_no_remote_fails() {
523        let dir = init_temp_repo().await;
524        // No remote configured, so fetch will fail
525        let outcome = start_rebase(dir.path(), "main").await;
526        assert!(matches!(outcome, RebaseOutcome::Failed(_)));
527    }
528
529    #[tokio::test]
530    async fn rebase_continue_resolves_conflict() {
531        let dir = init_temp_repo().await;
532        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
533
534        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
535        tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
536        run_git(dir.path(), &["add", "."]).await.unwrap();
537        run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
538
539        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
540        tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
541        run_git(dir.path(), &["add", "."]).await.unwrap();
542        run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
543
544        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
545        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
546            .await
547            .unwrap();
548
549        let outcome = start_rebase(dir.path(), &branch).await;
550        let files = match outcome {
551            RebaseOutcome::RebaseConflicts(f) => f,
552            other => panic!("expected RebaseConflicts, got {other:?}"),
553        };
554
555        // Manually resolve the conflict
556        tokio::fs::write(dir.path().join("README.md"), "resolved version").await.unwrap();
557
558        let result = rebase_continue(dir.path(), &files).await.unwrap();
559        assert!(result.is_none(), "expected rebase to complete, got more conflicts");
560
561        // Verify rebase completed (no rebase-merge dir)
562        assert!(!dir.path().join(".git/rebase-merge").exists());
563        assert!(!dir.path().join(".git/rebase-apply").exists());
564    }
565
566    #[tokio::test]
567    async fn start_rebase_skips_empty_commit() {
568        let dir = init_temp_repo().await;
569        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
570
571        // Create a feature branch with a change
572        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
573        tokio::fs::write(dir.path().join("README.md"), "changed").await.unwrap();
574        run_git(dir.path(), &["add", "."]).await.unwrap();
575        run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
576
577        // Cherry-pick the same change onto base so the feature commit becomes empty
578        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
579        tokio::fs::write(dir.path().join("README.md"), "changed").await.unwrap();
580        run_git(dir.path(), &["add", "."]).await.unwrap();
581        run_git(dir.path(), &["commit", "-m", "same change on base"]).await.unwrap();
582
583        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
584        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
585            .await
586            .unwrap();
587
588        // Rebase should skip the empty commit and succeed
589        let outcome = start_rebase(dir.path(), &branch).await;
590        assert!(
591            matches!(outcome, RebaseOutcome::Clean),
592            "expected Clean after skipping empty commit, got {outcome:?}"
593        );
594
595        // Verify rebase completed
596        assert!(!dir.path().join(".git/rebase-merge").exists());
597        assert!(!dir.path().join(".git/rebase-apply").exists());
598    }
599
600    #[tokio::test]
601    async fn rebase_continue_skips_empty_commit_after_real_conflict() {
602        let dir = init_temp_repo().await;
603        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
604
605        // Feature branch: commit 1 changes README, commit 2 changes other.txt
606        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
607        tokio::fs::write(dir.path().join("README.md"), "feature readme").await.unwrap();
608        run_git(dir.path(), &["add", "."]).await.unwrap();
609        run_git(dir.path(), &["commit", "-m", "feature readme"]).await.unwrap();
610
611        tokio::fs::write(dir.path().join("other.txt"), "feature other").await.unwrap();
612        run_git(dir.path(), &["add", "."]).await.unwrap();
613        run_git(dir.path(), &["commit", "-m", "feature other"]).await.unwrap();
614
615        // Base branch: conflict on README, cherry-pick the same other.txt change
616        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
617        tokio::fs::write(dir.path().join("README.md"), "base readme").await.unwrap();
618        run_git(dir.path(), &["add", "."]).await.unwrap();
619        run_git(dir.path(), &["commit", "-m", "base readme"]).await.unwrap();
620
621        tokio::fs::write(dir.path().join("other.txt"), "feature other").await.unwrap();
622        run_git(dir.path(), &["add", "."]).await.unwrap();
623        run_git(dir.path(), &["commit", "-m", "same other on base"]).await.unwrap();
624
625        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
626        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
627            .await
628            .unwrap();
629
630        // First commit (README) will have a real conflict
631        let outcome = start_rebase(dir.path(), &branch).await;
632        let files = match outcome {
633            RebaseOutcome::RebaseConflicts(f) => f,
634            other => panic!("expected RebaseConflicts, got {other:?}"),
635        };
636        assert!(files.contains(&"README.md".to_string()));
637
638        // Resolve the conflict manually
639        tokio::fs::write(dir.path().join("README.md"), "resolved").await.unwrap();
640
641        // Continue should resolve the conflict, then skip the empty second commit
642        let result = rebase_continue(dir.path(), &files).await.unwrap();
643        assert!(result.is_none(), "expected rebase to complete, got more conflicts");
644
645        assert!(!dir.path().join(".git/rebase-merge").exists());
646        assert!(!dir.path().join(".git/rebase-apply").exists());
647    }
648}