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/// Get the default branch name (main or master).
138pub async fn default_branch(repo_dir: &Path) -> Result<String> {
139    // Try symbolic-ref first
140    if let Ok(output) = run_git(repo_dir, &["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
141        if let Some(branch) = output.strip_prefix("refs/remotes/origin/") {
142            return Ok(branch.to_string());
143        }
144    }
145
146    // Fallback: check if main exists, otherwise master
147    if run_git(repo_dir, &["rev-parse", "--verify", "main"]).await.is_ok() {
148        return Ok("main".to_string());
149    }
150    if run_git(repo_dir, &["rev-parse", "--verify", "master"]).await.is_ok() {
151        return Ok("master".to_string());
152    }
153
154    // Last resort: whatever HEAD points to
155    let output = run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
156        .await
157        .context("detecting default branch")?;
158    Ok(output)
159}
160
161async fn run_git(repo_dir: &Path, args: &[&str]) -> Result<String> {
162    let output = Command::new("git")
163        .args(args)
164        .current_dir(repo_dir)
165        .kill_on_drop(true)
166        .output()
167        .await
168        .context("spawning git")?;
169
170    if !output.status.success() {
171        let stderr = String::from_utf8_lossy(&output.stderr);
172        anyhow::bail!("git {} failed: {}", args.join(" "), stderr.trim());
173    }
174
175    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    async fn init_temp_repo() -> tempfile::TempDir {
183        let dir = tempfile::tempdir().unwrap();
184
185        // Init a repo with an initial commit so we have a branch to work from
186        Command::new("git").args(["init"]).current_dir(dir.path()).output().await.unwrap();
187
188        Command::new("git")
189            .args(["config", "user.email", "test@test.com"])
190            .current_dir(dir.path())
191            .output()
192            .await
193            .unwrap();
194
195        Command::new("git")
196            .args(["config", "user.name", "Test"])
197            .current_dir(dir.path())
198            .output()
199            .await
200            .unwrap();
201
202        tokio::fs::write(dir.path().join("README.md"), "hello").await.unwrap();
203
204        Command::new("git").args(["add", "."]).current_dir(dir.path()).output().await.unwrap();
205
206        Command::new("git")
207            .args(["commit", "-m", "initial"])
208            .current_dir(dir.path())
209            .output()
210            .await
211            .unwrap();
212
213        dir
214    }
215
216    #[tokio::test]
217    async fn create_and_remove_worktree() {
218        let dir = init_temp_repo().await;
219
220        // Detect the current branch name
221        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
222
223        let wt = create_worktree(dir.path(), 42, &branch).await.unwrap();
224        assert!(wt.path.exists());
225        assert!(wt.branch.starts_with("oven/issue-42-"));
226        assert_eq!(wt.issue_number, 42);
227
228        remove_worktree(dir.path(), &wt.path).await.unwrap();
229        assert!(!wt.path.exists());
230    }
231
232    #[tokio::test]
233    async fn list_worktrees_includes_created() {
234        let dir = init_temp_repo().await;
235        let branch = run_git(dir.path(), &["rev-parse", "--abbrev-ref", "HEAD"]).await.unwrap();
236
237        let _wt = create_worktree(dir.path(), 99, &branch).await.unwrap();
238
239        let worktrees = list_worktrees(dir.path()).await.unwrap();
240        // Should have at least the main worktree + the one we created
241        assert!(worktrees.len() >= 2);
242        assert!(
243            worktrees
244                .iter()
245                .any(|w| { w.branch.as_deref().is_some_and(|b| b.starts_with("oven/issue-99-")) })
246        );
247    }
248
249    #[tokio::test]
250    async fn branch_naming_convention() {
251        let name = branch_name(123);
252        assert!(name.starts_with("oven/issue-123-"));
253        assert_eq!(name.len(), "oven/issue-123-".len() + 8);
254        // The hex part should be valid hex
255        let hex_part = &name["oven/issue-123-".len()..];
256        assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
257    }
258
259    #[tokio::test]
260    async fn default_branch_detection() {
261        let dir = init_temp_repo().await;
262        let branch = default_branch(dir.path()).await.unwrap();
263        // git init creates "main" or "master" depending on config
264        assert!(branch == "main" || branch == "master", "got: {branch}");
265    }
266
267    #[tokio::test]
268    async fn error_on_non_git_dir() {
269        let dir = tempfile::tempdir().unwrap();
270        let result = list_worktrees(dir.path()).await;
271        assert!(result.is_err());
272    }
273}