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/// Rebase the current branch onto the latest `origin/<base_branch>`.
147///
148/// Fetches the base branch first, then attempts a rebase. If the rebase fails
149/// (merge conflicts), it aborts the rebase and returns an error.
150pub async fn rebase_on_base(repo_dir: &Path, base_branch: &str) -> Result<()> {
151    run_git(repo_dir, &["fetch", "origin", base_branch])
152        .await
153        .context("fetching base branch before rebase")?;
154
155    let target = format!("origin/{base_branch}");
156    if run_git(repo_dir, &["rebase", &target]).await.is_ok() {
157        return Ok(());
158    }
159
160    let _ = run_git(repo_dir, &["rebase", "--abort"]).await;
161    anyhow::bail!("merge conflicts with {base_branch} that could not be automatically resolved")
162}
163
164/// Outcome of a rebase attempt with fallbacks.
165#[derive(Debug)]
166pub enum RebaseOutcome {
167    /// Rebase succeeded cleanly.
168    Clean,
169    /// Rebase had conflicts, fell back to a merge commit.
170    MergeFallback,
171    /// Both rebase and merge failed. The working tree has unresolved merge
172    /// conflicts -- the caller can attempt agent-assisted resolution before
173    /// calling [`abort_merge`] or committing.
174    MergeConflicts(Vec<String>),
175    /// Agent resolved the merge conflicts after both rebase and merge failed.
176    AgentResolved,
177    /// Unrecoverable failure (e.g. fetch failed).
178    Failed(String),
179}
180
181/// Rebase the current branch onto the latest `origin/<base_branch>` with fallbacks.
182///
183/// Tries rebase first. If that fails (merge conflicts), falls back to a merge
184/// commit. If both fail, returns `MergeConflicts` with the list of conflicting
185/// files -- the working tree is left in a conflicted state so the caller can
186/// attempt agent-assisted resolution.
187pub async fn rebase_with_fallbacks(repo_dir: &Path, base_branch: &str) -> RebaseOutcome {
188    if let Err(e) = run_git(repo_dir, &["fetch", "origin", base_branch]).await {
189        return RebaseOutcome::Failed(format!("failed to fetch {base_branch}: {e}"));
190    }
191
192    let target = format!("origin/{base_branch}");
193
194    // Try 1: rebase
195    if run_git(repo_dir, &["rebase", &target]).await.is_ok() {
196        return RebaseOutcome::Clean;
197    }
198    let _ = run_git(repo_dir, &["rebase", "--abort"]).await;
199
200    // Try 2: merge
201    if run_git(repo_dir, &["merge", &target, "--no-edit"]).await.is_ok() {
202        return RebaseOutcome::MergeFallback;
203    }
204
205    // Merge failed with conflicts -- leave the tree in conflict state so the
206    // caller can attempt agent resolution. List the conflicting files.
207    let conflicting = conflicting_files(repo_dir).await;
208    RebaseOutcome::MergeConflicts(conflicting)
209}
210
211/// List files with unresolved merge conflicts.
212pub async fn conflicting_files(repo_dir: &Path) -> Vec<String> {
213    run_git(repo_dir, &["diff", "--name-only", "--diff-filter=U"])
214        .await
215        .map_or_else(|_| vec![], |output| output.lines().map(String::from).collect())
216}
217
218/// Abort an in-progress merge.
219pub async fn abort_merge(repo_dir: &Path) {
220    let _ = run_git(repo_dir, &["merge", "--abort"]).await;
221}
222
223/// Stage resolved conflict files and commit (used after agent conflict resolution).
224///
225/// Only stages files that git considers part of the merge (previously conflicting),
226/// rather than `git add -A` which could accidentally stage untracked files the
227/// agent left behind.
228pub async fn commit_merge(repo_dir: &Path, conflicting: &[String]) -> Result<()> {
229    for file in conflicting {
230        run_git(repo_dir, &["add", file]).await.with_context(|| format!("staging {file}"))?;
231    }
232    run_git(repo_dir, &["commit", "--no-edit"]).await.context("committing merge resolution")?;
233    Ok(())
234}
235
236/// Get the default branch name (main or master).
237pub async fn default_branch(repo_dir: &Path) -> Result<String> {
238    // Try symbolic-ref first
239    if let Ok(output) = run_git(repo_dir, &["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
240        if let Some(branch) = output.strip_prefix("refs/remotes/origin/") {
241            return Ok(branch.to_string());
242        }
243    }
244
245    // Fallback: check if main exists, otherwise master
246    if run_git(repo_dir, &["rev-parse", "--verify", "main"]).await.is_ok() {
247        return Ok("main".to_string());
248    }
249    if run_git(repo_dir, &["rev-parse", "--verify", "master"]).await.is_ok() {
250        return Ok("master".to_string());
251    }
252
253    // Last resort: whatever HEAD points to
254    let output = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
255        .await
256        .context("detecting default branch")?;
257    Ok(output)
258}
259
260async fn run_git(repo_dir: &Path, args: &[&str]) -> Result<String> {
261    let output = Command::new("git")
262        .args(args)
263        .current_dir(repo_dir)
264        .kill_on_drop(true)
265        .output()
266        .await
267        .context("spawning git")?;
268
269    if !output.status.success() {
270        let stderr = String::from_utf8_lossy(&output.stderr);
271        anyhow::bail!("git {} failed: {}", args.join(" "), stderr.trim());
272    }
273
274    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    async fn init_temp_repo() -> tempfile::TempDir {
282        let dir = tempfile::tempdir().unwrap();
283
284        // Init a repo with an initial commit so we have a branch to work from
285        Command::new("git").args(["init"]).current_dir(dir.path()).output().await.unwrap();
286
287        Command::new("git")
288            .args(["config", "user.email", "test@test.com"])
289            .current_dir(dir.path())
290            .output()
291            .await
292            .unwrap();
293
294        Command::new("git")
295            .args(["config", "user.name", "Test"])
296            .current_dir(dir.path())
297            .output()
298            .await
299            .unwrap();
300
301        tokio::fs::write(dir.path().join("README.md"), "hello").await.unwrap();
302
303        Command::new("git").args(["add", "."]).current_dir(dir.path()).output().await.unwrap();
304
305        Command::new("git")
306            .args(["commit", "-m", "initial"])
307            .current_dir(dir.path())
308            .output()
309            .await
310            .unwrap();
311
312        dir
313    }
314
315    #[tokio::test]
316    async fn create_and_remove_worktree() {
317        let dir = init_temp_repo().await;
318
319        // Detect the current branch name
320        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
321
322        let wt = create_worktree(dir.path(), 42, &branch).await.unwrap();
323        assert!(wt.path.exists());
324        assert!(wt.branch.starts_with("oven/issue-42-"));
325        assert_eq!(wt.issue_number, 42);
326
327        remove_worktree(dir.path(), &wt.path).await.unwrap();
328        assert!(!wt.path.exists());
329    }
330
331    #[tokio::test]
332    async fn list_worktrees_includes_created() {
333        let dir = init_temp_repo().await;
334        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
335
336        let _wt = create_worktree(dir.path(), 99, &branch).await.unwrap();
337
338        let worktrees = list_worktrees(dir.path()).await.unwrap();
339        // Should have at least the main worktree + the one we created
340        assert!(worktrees.len() >= 2);
341        assert!(
342            worktrees
343                .iter()
344                .any(|w| { w.branch.as_deref().is_some_and(|b| b.starts_with("oven/issue-99-")) })
345        );
346    }
347
348    #[tokio::test]
349    async fn branch_naming_convention() {
350        let name = branch_name(123);
351        assert!(name.starts_with("oven/issue-123-"));
352        assert_eq!(name.len(), "oven/issue-123-".len() + 8);
353        // The hex part should be valid hex
354        let hex_part = &name["oven/issue-123-".len()..];
355        assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
356    }
357
358    #[tokio::test]
359    async fn default_branch_detection() {
360        let dir = init_temp_repo().await;
361        let branch = default_branch(dir.path()).await.unwrap();
362        // git init creates "main" or "master" depending on config
363        assert!(branch == "main" || branch == "master", "got: {branch}");
364    }
365
366    #[tokio::test]
367    async fn error_on_non_git_dir() {
368        let dir = tempfile::tempdir().unwrap();
369        let result = list_worktrees(dir.path()).await;
370        assert!(result.is_err());
371    }
372
373    #[tokio::test]
374    async fn rebase_on_base_clean() {
375        let dir = init_temp_repo().await;
376        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
377
378        // Create a feature branch with a non-conflicting change
379        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
380        tokio::fs::write(dir.path().join("feature.txt"), "feature work").await.unwrap();
381        run_git(dir.path(), &["add", "."]).await.unwrap();
382        run_git(dir.path(), &["commit", "-m", "feature commit"]).await.unwrap();
383
384        // Add a non-conflicting commit on the base branch
385        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
386        tokio::fs::write(dir.path().join("base.txt"), "base work").await.unwrap();
387        run_git(dir.path(), &["add", "."]).await.unwrap();
388        run_git(dir.path(), &["commit", "-m", "base commit"]).await.unwrap();
389
390        // Go back to feature branch and rebase
391        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
392
393        // rebase_on_base fetches from origin, so we need a remote.
394        // Use the repo itself as origin for testing.
395        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
396            .await
397            .unwrap();
398
399        let result = rebase_on_base(dir.path(), &branch).await;
400        assert!(result.is_ok());
401
402        // Verify both files exist after rebase
403        assert!(dir.path().join("feature.txt").exists());
404        assert!(dir.path().join("base.txt").exists());
405    }
406
407    #[tokio::test]
408    async fn rebase_on_base_conflict_aborts() {
409        let dir = init_temp_repo().await;
410        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
411
412        // Create a feature branch with a conflicting change to README.md
413        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
414        tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
415        run_git(dir.path(), &["add", "."]).await.unwrap();
416        run_git(dir.path(), &["commit", "-m", "feature conflict"]).await.unwrap();
417
418        // Add a conflicting commit on the base branch
419        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
420        tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
421        run_git(dir.path(), &["add", "."]).await.unwrap();
422        run_git(dir.path(), &["commit", "-m", "base conflict"]).await.unwrap();
423
424        // Go back to feature and set up origin
425        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
426        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
427            .await
428            .unwrap();
429
430        let result = rebase_on_base(dir.path(), &branch).await;
431        assert!(result.is_err());
432        assert!(
433            result.unwrap_err().to_string().contains("merge conflicts"),
434            "error should mention merge conflicts"
435        );
436
437        // Verify rebase was aborted (no .git/rebase-merge directory)
438        assert!(!dir.path().join(".git/rebase-merge").exists());
439    }
440
441    #[tokio::test]
442    async fn force_push_branch_works() {
443        let dir = init_temp_repo().await;
444
445        // Set up a bare remote to push to
446        let remote_dir = tempfile::tempdir().unwrap();
447        Command::new("git")
448            .args(["clone", "--bare", &dir.path().to_string_lossy(), "."])
449            .current_dir(remote_dir.path())
450            .output()
451            .await
452            .unwrap();
453
454        run_git(dir.path(), &["remote", "add", "origin", &remote_dir.path().to_string_lossy()])
455            .await
456            .unwrap();
457
458        // Create a branch, push it, then amend and force-push
459        run_git(dir.path(), &["checkout", "-b", "test-branch"]).await.unwrap();
460        tokio::fs::write(dir.path().join("new.txt"), "v1").await.unwrap();
461        run_git(dir.path(), &["add", "."]).await.unwrap();
462        run_git(dir.path(), &["commit", "-m", "v1"]).await.unwrap();
463        push_branch(dir.path(), "test-branch").await.unwrap();
464
465        // Amend the commit (simulating a rebase)
466        tokio::fs::write(dir.path().join("new.txt"), "v2").await.unwrap();
467        run_git(dir.path(), &["add", "."]).await.unwrap();
468        run_git(dir.path(), &["commit", "--amend", "-m", "v2"]).await.unwrap();
469
470        // Regular push should fail, force push should succeed
471        assert!(push_branch(dir.path(), "test-branch").await.is_err());
472        assert!(force_push_branch(dir.path(), "test-branch").await.is_ok());
473    }
474
475    #[tokio::test]
476    async fn rebase_with_fallbacks_clean() {
477        let dir = init_temp_repo().await;
478        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
479
480        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
481        tokio::fs::write(dir.path().join("feature.txt"), "feature work").await.unwrap();
482        run_git(dir.path(), &["add", "."]).await.unwrap();
483        run_git(dir.path(), &["commit", "-m", "feature commit"]).await.unwrap();
484
485        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
486        tokio::fs::write(dir.path().join("base.txt"), "base work").await.unwrap();
487        run_git(dir.path(), &["add", "."]).await.unwrap();
488        run_git(dir.path(), &["commit", "-m", "base commit"]).await.unwrap();
489
490        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
491        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
492            .await
493            .unwrap();
494
495        let outcome = rebase_with_fallbacks(dir.path(), &branch).await;
496        assert!(matches!(outcome, RebaseOutcome::Clean));
497        assert!(dir.path().join("feature.txt").exists());
498        assert!(dir.path().join("base.txt").exists());
499    }
500
501    #[tokio::test]
502    async fn rebase_with_fallbacks_merge_fallback() {
503        let dir = init_temp_repo().await;
504        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
505
506        // Create a feature branch that modifies README.md
507        run_git(dir.path(), &["checkout", "-b", "feature"]).await.unwrap();
508        tokio::fs::write(dir.path().join("README.md"), "feature version").await.unwrap();
509        run_git(dir.path(), &["add", "."]).await.unwrap();
510        run_git(dir.path(), &["commit", "-m", "feature change"]).await.unwrap();
511
512        // Create a conflicting change on the base branch
513        run_git(dir.path(), &["checkout", &branch]).await.unwrap();
514        tokio::fs::write(dir.path().join("README.md"), "base version").await.unwrap();
515        run_git(dir.path(), &["add", "."]).await.unwrap();
516        run_git(dir.path(), &["commit", "-m", "base change"]).await.unwrap();
517
518        run_git(dir.path(), &["checkout", "feature"]).await.unwrap();
519        run_git(dir.path(), &["remote", "add", "origin", &dir.path().to_string_lossy()])
520            .await
521            .unwrap();
522
523        // Rebase will conflict, but merge should succeed (git merge auto-resolves
524        // content conflicts differently than rebase in some cases). If both fail,
525        // MergeConflicts is returned with the conflicting files.
526        let outcome = rebase_with_fallbacks(dir.path(), &branch).await;
527        assert!(
528            matches!(outcome, RebaseOutcome::MergeFallback | RebaseOutcome::MergeConflicts(_)),
529            "expected MergeFallback or MergeConflicts, got {outcome:?}"
530        );
531    }
532
533    #[tokio::test]
534    async fn rebase_with_fallbacks_no_remote_fails() {
535        let dir = init_temp_repo().await;
536        // No remote configured, so fetch will fail
537        let outcome = rebase_with_fallbacks(dir.path(), "main").await;
538        assert!(matches!(outcome, RebaseOutcome::Failed(_)));
539    }
540}