use anyhow::{anyhow, Context, Result};
use std::process::Command as GitCommand;
#[derive(Debug, Clone, Copy)]
pub enum PrSummaryMode {
ByCommits,
ByPrs,
}
impl PrSummaryMode {
pub fn as_str(&self) -> &'static str {
match self {
PrSummaryMode::ByCommits => "commits",
PrSummaryMode::ByPrs => "prs",
}
}
}
#[derive(Debug, Clone)]
pub struct PrItem {
pub commit_hash: String,
pub title: String,
pub body: String,
pub pr_number: Option<u32>,
}
pub fn git_output(args: &[&str]) -> Result<String> {
let output = GitCommand::new("git")
.args(args)
.output()
.with_context(|| format!("failed to run git {:?}", args))?;
if !output.status.success() {
return Err(anyhow!(
"git {:?} exited with status {:?}",
args,
output.status.code()
));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn current_branch() -> Result<String> {
let name = git_output(&["rev-parse", "--abbrev-ref", "HEAD"])?
.trim()
.to_string();
Ok(name)
}
pub fn staged_files() -> Result<Vec<String>> {
let output = git_output(&["diff", "--cached", "--name-only"])?;
let files = output
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
Ok(files)
}
pub fn staged_diff_for_file(path: &str) -> Result<String> {
let diff = git_output(&["diff", "--cached", "--", path])?;
Ok(diff)
}
fn find_first_pr_number(text: &str) -> Option<u32> {
let bytes = text.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] == b'#' {
let mut j = i + 1;
let mut value: u32 = 0;
let mut found_digit = false;
while j < len {
let b = bytes[j];
if b.is_ascii_digit() {
found_digit = true;
value = value
.saturating_mul(10)
.saturating_add((b - b'0') as u32);
j += 1;
} else {
break;
}
}
if found_digit {
return Some(value);
}
}
i += 1;
}
None
}
pub fn collect_pr_items(base: &str, from: &str) -> Result<Vec<PrItem>> {
let range = format!("{base}..{from}");
let log_output = git_output(&[
"log",
"--reverse",
"--pretty=format:%H%n%s%n%b%n---END---",
&range,
])?;
if log_output.trim().is_empty() {
return Ok(vec![]);
}
let mut items = Vec::new();
for block in log_output.split("\n---END---") {
let block = block.trim();
if block.is_empty() {
continue;
}
let mut lines = block.lines();
let hash = match lines.next() {
Some(h) => h.trim().to_string(),
None => continue,
};
let title = lines.next().unwrap_or("").trim().to_string();
let body = lines.collect::<Vec<_>>().join("\n");
let mut pr_number = find_first_pr_number(&title);
if pr_number.is_none() {
pr_number = find_first_pr_number(&body);
}
items.push(PrItem {
commit_hash: hash,
title,
body,
pr_number,
});
}
Ok(items)
}
pub fn split_diff_by_file(diff: &str) -> Vec<(String, String)> {
let mut results = Vec::new();
let mut current_path: Option<String> = None;
let mut current_lines: Vec<&str> = Vec::new();
for line in diff.lines() {
if line.starts_with("diff --git ") {
if let Some(path) = current_path.take() {
results.push((path, current_lines.join("\n")));
}
current_lines = vec![line];
let path = line
.split_whitespace()
.last()
.and_then(|s| s.strip_prefix("b/"))
.unwrap_or("")
.to_string();
current_path = Some(path);
} else {
current_lines.push(line);
}
}
if let Some(path) = current_path.take() {
results.push((path, current_lines.join("\n")));
}
results
}
pub fn stage_all() -> Result<()> {
log::info!("Staging all changes");
git_output(&["add", "-A"])?;
Ok(())
}
pub fn detect_repo_id() -> Option<String> {
use std::process::Command;
let output = Command::new("git")
.args(["config", "--get", "remote.origin.url"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8(output.stdout).ok()?;
let trimmed = url.trim().trim_end_matches(".git");
let path = if let Some(idx) = trimmed.find("://") {
let rest = &trimmed[idx + 3..];
match rest.find('/') {
Some(slash) => &rest[slash + 1..],
None => rest,
}
} else if let Some(idx) = trimmed.find(':') {
&trimmed[idx + 1..]
} else {
trimmed
};
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() >= 2 {
let owner = segments[segments.len() - 2];
let repo = segments[segments.len() - 1];
Some(format!("{}/{}", owner, repo))
} else {
None
}
}