use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchInfo {
pub name: String,
pub base_branch: String,
pub worktree_path: Option<String>,
}
pub struct BranchManager {
branch_prefix: String,
}
impl BranchManager {
pub fn new(branch_prefix: String) -> Self {
Self { branch_prefix }
}
pub fn branch_name(&self, issue_number: u64, slug: &str) -> String {
let clean_slug: String = slug
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.take(40)
.collect();
let clean_slug = clean_slug.trim_matches('-').to_string();
format!(
"{}issue-{}-{}",
self.branch_prefix, issue_number, clean_slug
)
}
pub async fn create_branch(
&self,
repo_path: &str,
branch_name: &str,
base_branch: &str,
) -> anyhow::Result<BranchInfo> {
let _ = tokio::process::Command::new("git")
.args(["fetch", "origin", base_branch])
.current_dir(repo_path)
.output()
.await;
let output = tokio::process::Command::new("git")
.args([
"checkout",
"-b",
branch_name,
&format!("origin/{base_branch}"),
])
.current_dir(repo_path)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to create branch {branch_name}: {stderr}");
}
Ok(BranchInfo {
name: branch_name.to_string(),
base_branch: base_branch.to_string(),
worktree_path: None,
})
}
pub async fn push_branch(&self, repo_path: &str, branch_name: &str) -> anyhow::Result<()> {
let output = tokio::process::Command::new("git")
.args(["push", "-u", "origin", branch_name])
.current_dir(repo_path)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to push branch {branch_name}: {stderr}");
}
Ok(())
}
pub async fn delete_branch(&self, repo_path: &str, branch_name: &str) -> anyhow::Result<()> {
let _ = tokio::process::Command::new("git")
.args(["branch", "-D", branch_name])
.current_dir(repo_path)
.output()
.await;
let _ = tokio::process::Command::new("git")
.args(["push", "origin", "--delete", branch_name])
.current_dir(repo_path)
.output()
.await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn branch_name_basic() {
let bm = BranchManager::new("autonomy/".to_string());
let name = bm.branch_name(42, "fix login bug");
assert_eq!(name, "autonomy/issue-42-fix-login-bug");
}
#[test]
fn branch_name_sanitizes_special_chars() {
let bm = BranchManager::new("fix/".to_string());
let name = bm.branch_name(7, "Hello, World! @#$%");
assert!(name.starts_with("fix/issue-7-"));
assert!(!name.ends_with('-'));
let slug_part = name.strip_prefix("fix/issue-7-").unwrap();
assert!(slug_part.chars().all(|c| c.is_alphanumeric() || c == '-'));
}
#[test]
fn branch_name_truncates_long_slugs() {
let bm = BranchManager::new("auto/".to_string());
let long_slug = "a".repeat(100);
let name = bm.branch_name(1, &long_slug);
let slug_part = name.strip_prefix("auto/issue-1-").unwrap();
assert!(slug_part.len() <= 40);
}
}