use git2::Repository;
use std::process::Command;
use super::{get_current_branch, GitError};
use crate::util::log_cmd;
pub fn create_and_checkout_branch(repo: &Repository, branch_name: &str) -> Result<(), GitError> {
let repo_path = super::get_workdir(repo);
let mut cmd = Command::new("git");
cmd.args(["checkout", "-b", branch_name])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd
.output()
.map_err(|e| GitError::OperationFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("is already used by worktree at") {
if let Some(path_start) = stderr.find("worktree at '") {
let path_part = &stderr[path_start + 13..];
if let Some(path_end) = path_part.find('\'') {
let worktree_path = &path_part[..path_end];
return Err(GitError::OperationFailed(format!(
"Branch '{}' is checked out in another worktree at '{}'. \
Use a different branch name or work in that worktree.",
branch_name, worktree_path
)));
}
}
return Err(GitError::OperationFailed(format!(
"Branch '{}' is already checked out in another worktree. \
Use a different branch name or work in that worktree.",
branch_name
)));
}
return Err(GitError::OperationFailed(stderr.to_string()));
}
Ok(())
}
pub fn checkout_branch(repo: &Repository, branch_name: &str) -> Result<(), GitError> {
let repo_path = super::get_workdir(repo);
if !branch_exists(repo, branch_name) {
return Err(GitError::BranchNotFound(branch_name.to_string()));
}
let mut cmd = Command::new("git");
cmd.args(["checkout", branch_name]).current_dir(repo_path);
log_cmd(&cmd);
let output = cmd
.output()
.map_err(|e| GitError::OperationFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("is already used by worktree at") {
if let Some(path_start) = stderr.find("worktree at '") {
let path_part = &stderr[path_start + 13..];
if let Some(path_end) = path_part.find('\'') {
let worktree_path = &path_part[..path_end];
return Err(GitError::OperationFailed(format!(
"Branch '{}' is checked out in another worktree at '{}'. \
Either use that worktree or create a new branch with 'gr branch <name>'",
branch_name, worktree_path
)));
}
}
return Err(GitError::OperationFailed(format!(
"Branch '{}' is already checked out in another worktree. \
Either use that worktree or create a new branch with 'gr branch <name>'",
branch_name
)));
}
return Err(GitError::OperationFailed(stderr.to_string()));
}
Ok(())
}
pub fn checkout_branch_at_upstream(
repo: &Repository,
branch_name: &str,
upstream: &str,
) -> Result<(), GitError> {
let repo_path = super::get_workdir(repo);
let mut cmd = Command::new("git");
cmd.args(["checkout", "-B", branch_name, upstream])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd
.output()
.map_err(|e| GitError::OperationFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("is already used by worktree at") {
if let Some(path_start) = stderr.find("worktree at '") {
let path_part = &stderr[path_start + 13..];
if let Some(path_end) = path_part.find('\'') {
let worktree_path = &path_part[..path_end];
return Err(GitError::OperationFailed(format!(
"Branch '{}' is checked out in another worktree at '{}'. \
Use that worktree or choose a different branch.",
branch_name, worktree_path
)));
}
}
return Err(GitError::OperationFailed(format!(
"Branch '{}' is already checked out in another worktree. \
Use that worktree or choose a different branch.",
branch_name
)));
}
return Err(GitError::OperationFailed(stderr.to_string()));
}
Ok(())
}
pub fn checkout_detached(repo: &Repository, target: &str) -> Result<(), GitError> {
let repo_path = super::get_workdir(repo);
let mut cmd = Command::new("git");
cmd.args(["checkout", "--detach", "-f", target])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd
.output()
.map_err(|e| GitError::OperationFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitError::OperationFailed(stderr.to_string()));
}
Ok(())
}
pub fn branch_exists(repo: &Repository, branch_name: &str) -> bool {
let repo_path = super::get_workdir(repo);
let mut cmd = Command::new("git");
cmd.args([
"rev-parse",
"--verify",
&format!("refs/heads/{}", branch_name),
])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd.output();
output.map(|o| o.status.success()).unwrap_or(false)
}
pub fn remote_branch_exists(repo: &Repository, branch_name: &str, remote: &str) -> bool {
let repo_path = super::get_workdir(repo);
let mut cmd = Command::new("git");
cmd.args([
"rev-parse",
"--verify",
&format!("refs/remotes/{}/{}", remote, branch_name),
])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd.output();
output.map(|o| o.status.success()).unwrap_or(false)
}
pub fn delete_local_branch(
repo: &Repository,
branch_name: &str,
force: bool,
) -> Result<(), GitError> {
let repo_path = super::get_workdir(repo);
let current = get_current_branch(repo)?;
if current == branch_name {
return Err(GitError::OperationFailed(
"Cannot delete the currently checked out branch".to_string(),
));
}
let flag = if force { "-D" } else { "-d" };
let mut cmd = Command::new("git");
cmd.args(["branch", flag, branch_name])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd
.output()
.map_err(|e| GitError::OperationFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not fully merged") {
return Err(GitError::OperationFailed(format!(
"Branch '{}' is not fully merged. Use force to delete anyway.",
branch_name
)));
}
return Err(GitError::OperationFailed(stderr.to_string()));
}
Ok(())
}
pub fn is_branch_merged(
repo: &Repository,
branch_name: &str,
target_branch: &str,
) -> Result<bool, GitError> {
let repo_path = super::get_workdir(repo);
let mut cmd = Command::new("git");
cmd.args(["branch", "--merged", target_branch])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd
.output()
.map_err(|e| GitError::OperationFailed(e.to_string()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.lines()
.any(|line| line.trim().trim_start_matches("* ") == branch_name))
}
pub fn list_local_branches(repo: &Repository) -> Result<Vec<String>, GitError> {
let repo_path = super::get_workdir(repo);
let mut cmd = Command::new("git");
cmd.args(["branch", "--format=%(refname:short)"])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd
.output()
.map_err(|e| GitError::OperationFailed(e.to_string()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().map(|s| s.to_string()).collect())
}
pub fn list_remote_branches(repo: &Repository, remote: &str) -> Result<Vec<String>, GitError> {
let repo_path = super::get_workdir(repo);
let prefix = format!("{}/", remote);
let mut cmd = Command::new("git");
cmd.args(["branch", "-r", "--format=%(refname:short)"])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd
.output()
.map_err(|e| GitError::OperationFailed(e.to_string()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.lines()
.filter(|line| line.starts_with(&prefix))
.map(|line| line[prefix.len()..].to_string())
.collect())
}
pub fn get_commits_between(
repo: &Repository,
base_branch: &str,
head_branch: Option<&str>,
) -> Result<Vec<String>, GitError> {
let repo_path = super::get_workdir(repo);
let head_name = match head_branch {
Some(name) => name.to_string(),
None => get_current_branch(repo)?,
};
let range = format!("{}..{}", base_branch, head_name);
let mut cmd = Command::new("git");
cmd.args(["rev-list", &range]).current_dir(repo_path);
log_cmd(&cmd);
let output = cmd
.output()
.map_err(|e| GitError::OperationFailed(e.to_string()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().map(|s| s.to_string()).collect())
}
pub fn has_commits_ahead(repo: &Repository, base_branch: &str) -> Result<bool, GitError> {
let commits = get_commits_between(repo, base_branch, None)?;
Ok(!commits.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::open_repo;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn setup_test_repo() -> (TempDir, Repository) {
let temp = TempDir::new().unwrap();
Command::new("git")
.args(["init"])
.current_dir(temp.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp.path())
.output()
.unwrap();
fs::write(temp.path().join("README.md"), "# Test").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(temp.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp.path())
.output()
.unwrap();
let repo = open_repo(temp.path()).unwrap();
(temp, repo)
}
#[test]
fn test_create_and_checkout_branch() {
let (temp, repo) = setup_test_repo();
create_and_checkout_branch(&repo, "feature").unwrap();
let current = get_current_branch(&repo).unwrap();
assert_eq!(current, "feature");
}
#[test]
fn test_branch_exists() {
let (temp, repo) = setup_test_repo();
assert!(!branch_exists(&repo, "feature"));
create_and_checkout_branch(&repo, "feature").unwrap();
assert!(branch_exists(&repo, "feature"));
}
#[test]
fn test_checkout_branch() {
let (temp, repo) = setup_test_repo();
create_and_checkout_branch(&repo, "feature").unwrap();
let default = if branch_exists(&repo, "main") {
"main"
} else {
"master"
};
checkout_branch(&repo, default).unwrap();
let current = get_current_branch(&repo).unwrap();
assert_eq!(current, default);
}
#[test]
fn test_list_local_branches() {
let (temp, repo) = setup_test_repo();
create_and_checkout_branch(&repo, "feature1").unwrap();
create_and_checkout_branch(&repo, "feature2").unwrap();
let branches = list_local_branches(&repo).unwrap();
assert!(branches.contains(&"feature1".to_string()));
assert!(branches.contains(&"feature2".to_string()));
}
}