use anyhow::{Context, Result};
use std::process::Command;
pub fn is_git_repo() -> Result<bool> {
let output = Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()
.context("Failed to check if directory is a git repository")?;
Ok(output.status.success())
}
pub fn has_commits() -> Result<bool> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.context("Failed to check for commits")?;
Ok(output.status.success())
}
pub fn current_branch() -> Result<String> {
let output = Command::new("git")
.args(["branch", "--show-current"])
.output()
.context("Failed to get current branch")?;
if !output.status.success() {
anyhow::bail!("Failed to get current branch");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn default_branch() -> Result<String> {
let output = Command::new("git")
.args(["symbolic-ref", "refs/remotes/origin/HEAD"])
.output();
if let Ok(output) = output {
if output.status.success() {
let branch = String::from_utf8_lossy(&output.stdout);
if let Some(name) = branch.trim().strip_prefix("refs/remotes/origin/") {
return Ok(name.to_string());
}
}
}
if branch_exists("main")? {
Ok("main".to_string())
} else if branch_exists("master")? {
Ok("master".to_string())
} else {
current_branch()
}
}
pub fn branch_exists(name: &str) -> Result<bool> {
let output = Command::new("git")
.args(["rev-parse", "--verify", &format!("refs/heads/{}", name)])
.output()
.context("Failed to check if branch exists")?;
Ok(output.status.success())
}
pub fn is_clean() -> Result<bool> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.output()
.context("Failed to check git status")?;
Ok(output.stdout.is_empty())
}
pub fn status_count() -> Result<usize> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.output()
.context("Failed to get git status")?;
let count = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| !line.is_empty())
.count();
Ok(count)
}
pub fn commits_behind(current: &str, other: &str) -> Result<usize> {
let output = Command::new("git")
.args(["rev-list", "--count", &format!("{}..{}", current, other)])
.output()
.context("Failed to count commits behind")?;
if !output.status.success() {
return Ok(0);
}
let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
count_str.parse().context("Failed to parse commit count")
}
pub fn checkout_new_branch(name: &str, from: &str) -> Result<()> {
let output = Command::new("git")
.args(["checkout", "-b", name, from])
.output()
.context("Failed to create and checkout branch")?;
if !output.status.success() {
anyhow::bail!(
"Failed to create branch: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub fn branch_rename(old: &str, new: &str) -> Result<()> {
let output = Command::new("git")
.args(["branch", "-m", old, new])
.output()
.context("Failed to rename branch")?;
if !output.status.success() {
anyhow::bail!(
"Failed to rename branch: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub fn rename_current_branch(new_name: &str) -> Result<()> {
let output = Command::new("git")
.args(["branch", "-m", new_name])
.output()
.context("Failed to rename current branch")?;
if !output.status.success() {
anyhow::bail!(
"Failed to rename current branch: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub fn remote_url(remote: &str) -> Result<String> {
let output = Command::new("git")
.args(["remote", "get-url", remote])
.output()
.context("Failed to get remote URL")?;
if !output.status.success() {
anyhow::bail!("Remote '{}' not found", remote);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn has_remote(url: &str) -> Result<bool> {
let output = Command::new("git")
.args(["remote", "-v"])
.output()
.context("Failed to list remotes")?;
let remotes = String::from_utf8_lossy(&output.stdout);
Ok(remotes.contains(url))
}
pub fn add_remote(name: &str, url: &str) -> Result<()> {
let output = Command::new("git")
.args(["remote", "add", name, url])
.output()
.context("Failed to add remote")?;
if !output.status.success() {
anyhow::bail!(
"Failed to add remote: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub fn repo_name() -> Result<String> {
let url = remote_url("origin")?;
let (_, repo) = parse_github_url(&url)?;
Ok(repo)
}
pub fn parse_github_url(url: &str) -> Result<(String, String)> {
let cleaned = url
.trim()
.strip_suffix(".git")
.unwrap_or(url)
.replace("git@github.com:", "")
.replace("https://github.com/", "");
let parts: Vec<&str> = cleaned.split('/').collect();
if parts.len() >= 2 {
Ok((parts[0].to_string(), parts[1].to_string()))
} else {
anyhow::bail!("Invalid GitHub URL format: {}", url)
}
}
pub fn add_all() -> Result<()> {
let output = Command::new("git")
.args(["add", "."])
.output()
.context("Failed to run git add")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to stage changes: {}", stderr.trim());
}
Ok(())
}
pub fn add_paths(paths: &[&str]) -> Result<()> {
for path in paths {
if !std::path::Path::new(path).exists() {
continue;
}
if is_ignored(path) {
continue;
}
let output = Command::new("git")
.args(["add", path])
.output()
.context(format!("Failed to run git add {}", path))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to stage {}: {}", path, stderr.trim());
}
}
Ok(())
}
fn is_ignored(path: &str) -> bool {
Command::new("git")
.args(["check-ignore", "-q", path])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
pub fn has_staged_changes() -> Result<bool> {
let output = Command::new("git")
.args(["diff", "--cached", "--quiet"])
.output()
.context("Failed to check staged changes")?;
Ok(!output.status.success())
}
pub fn commit(message: &str) -> Result<()> {
let output = Command::new("git")
.args(["commit", "-m", message])
.output()
.context("Failed to create commit")?;
if !output.status.success() {
anyhow::bail!(
"Failed to create commit: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub fn stash_push(message: &str) -> Result<()> {
let output = Command::new("git")
.args(["stash", "push", "--include-untracked", "-m", message])
.output()
.context("Failed to stash changes")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to stash changes: {}", stderr);
}
Ok(())
}
pub fn checkout(branch: &str) -> Result<()> {
let output = Command::new("git")
.args(["checkout", branch])
.output()
.context("Failed to checkout branch")?;
if !output.status.success() {
anyhow::bail!(
"Failed to checkout {}: {}",
branch,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub fn rebase(onto: &str) -> Result<bool> {
let output = Command::new("git")
.args(["rebase", onto])
.output()
.context("Failed to rebase")?;
if output.status.success() {
Ok(true)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("CONFLICT") || stderr.contains("could not apply") {
Ok(false) } else {
anyhow::bail!("Failed to rebase onto {}: {}", onto, stderr);
}
}
}
pub fn rebase_abort() -> Result<()> {
let output = Command::new("git")
.args(["rebase", "--abort"])
.output()
.context("Failed to abort rebase")?;
if !output.status.success() {
anyhow::bail!(
"Failed to abort rebase: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub fn head_sha() -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.context("Failed to get HEAD SHA")?;
if !output.status.success() {
anyhow::bail!("Failed to get HEAD SHA (no commits?)");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn short_sha() -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.context("Failed to get short SHA")?;
if !output.status.success() {
anyhow::bail!("Failed to get short SHA (no commits?)");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn tag_exists(name: &str) -> Result<bool> {
let output = Command::new("git")
.args(["tag", "-l", name])
.output()
.context("Failed to check if tag exists")?;
let tags = String::from_utf8_lossy(&output.stdout);
Ok(tags.trim() == name)
}
pub fn has_upstream() -> Result<bool> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "@{upstream}"])
.output()
.context("Failed to check for upstream")?;
Ok(output.status.success())
}
pub fn commits_ahead() -> Result<usize> {
let output = Command::new("git")
.args(["rev-list", "--count", "@{upstream}..HEAD"])
.output()
.context("Failed to count commits ahead")?;
if !output.status.success() {
return Ok(0);
}
let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(count_str.parse().unwrap_or(0))
}
pub fn commits_behind_upstream() -> Result<usize> {
let output = Command::new("git")
.args(["rev-list", "--count", "HEAD..@{upstream}"])
.output()
.context("Failed to count commits behind")?;
if !output.status.success() {
return Ok(0);
}
let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(count_str.parse().unwrap_or(0))
}
pub fn is_diverged() -> Result<bool> {
if !has_upstream()? {
return Ok(false);
}
let ahead = commits_ahead()?;
let behind = commits_behind_upstream()?;
Ok(ahead > 0 && behind > 0)
}
pub fn fetch(remote: &str) -> Result<()> {
let output = Command::new("git")
.args(["fetch", remote])
.output()
.context("Failed to fetch from remote")?;
if !output.status.success() {
anyhow::bail!(
"Failed to fetch {}: {}",
remote,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub fn create_tag(name: &str, message: &str) -> Result<()> {
let output = Command::new("git")
.args(["tag", "-a", name, "-m", message])
.output()
.context("Failed to create git tag")?;
if !output.status.success() {
anyhow::bail!(
"Failed to create tag '{}': {}",
name,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub fn commits_since_count(since_sha: &str) -> Result<usize> {
let range = format!("{}..HEAD", since_sha);
let output = Command::new("git")
.args(["rev-list", "--count", &range])
.output()
.context("Failed to count commits since SHA")?;
if !output.status.success() {
return Ok(0);
}
let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(count_str.parse().unwrap_or(0))
}
pub fn last_commit_relative_time() -> Result<String> {
let output = Command::new("git")
.args(["log", "-1", "--format=%ar"])
.output()
.context("Failed to get last commit time")?;
if !output.status.success() {
anyhow::bail!("No commits found");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn last_commit_message() -> Result<String> {
let output = Command::new("git")
.args(["log", "-1", "--format=%s"])
.output()
.context("Failed to get last commit message")?;
if !output.status.success() {
anyhow::bail!("No commits found");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn diff_stat_summary() -> Result<String> {
let output = Command::new("git")
.args(["diff", "--stat"])
.output()
.context("Failed to get diff stat")?;
let stat = String::from_utf8_lossy(&output.stdout);
Ok(stat
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim()
.to_string())
}
pub fn status_porcelain() -> Result<String> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.output()
.context("Failed to get git status")?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn files_changed_since(from_ref: &str) -> Result<Vec<String>> {
let range = format!("{}..HEAD", from_ref);
let output = Command::new("git")
.args(["diff", "--name-only", &range])
.output()
.context("Failed to get files changed since ref")?;
if !output.status.success() {
return Ok(vec![]);
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect())
}
pub fn log_oneline(count: usize) -> Result<String> {
let output = Command::new("git")
.args(["log", "--oneline", &format!("-{}", count)])
.output()
.context("Failed to get recent commits")?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_github_url_ssh() {
let url = "git@github.com:dustproject/dust.git";
let (owner, repo) = parse_github_url(url).unwrap();
assert_eq!(owner, "dustproject");
assert_eq!(repo, "dust");
}
#[test]
fn test_parse_github_url_https() {
let url = "https://github.com/dustproject/dust.git";
let (owner, repo) = parse_github_url(url).unwrap();
assert_eq!(owner, "dustproject");
assert_eq!(repo, "dust");
}
}