use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Range {
Unstaged,
Staged,
Base(String),
}
impl Range {
pub fn describe(&self) -> String {
match self {
Range::Unstaged => "unstaged changes".into(),
Range::Staged => "staged changes".into(),
Range::Base(reference) => format!("changes since this branch left {reference}"),
}
}
pub fn id(&self) -> String {
match self {
Range::Unstaged => "unstaged".into(),
Range::Staged => "staged".into(),
Range::Base(reference) => format!("base:{reference}"),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Added,
Modified,
Deleted,
}
#[derive(Clone, Debug)]
pub struct Change {
pub path: String,
pub kind: Kind,
pub before: String,
pub after: String,
pub patch: String,
}
impl Change {
pub fn reviewable(&self) -> bool {
self.kind != Kind::Deleted && !self.after.is_empty()
}
}
fn git(root: &Path, arguments: &[&str]) -> Result<Vec<u8>, String> {
let output = Command::new("git")
.current_dir(root)
.args(arguments)
.output()
.map_err(|e| format!("cannot run git: {e}"))?;
if !output.status.success() {
let message = String::from_utf8_lossy(&output.stderr);
let message = message.trim();
return Err(if message.is_empty() {
format!("git {} failed", arguments.join(" "))
} else {
format!("git {}: {message}", arguments.join(" "))
});
}
Ok(output.stdout)
}
fn text(root: &Path, arguments: &[&str]) -> Result<String, String> {
let bytes = git(root, arguments)?;
String::from_utf8(bytes).map_err(|_| "git returned output that is not UTF-8".to_string())
}
fn blob(root: &Path, revision: &str, path: &str) -> Result<String, String> {
let spec = format!("{revision}:{path}");
let bytes = git(root, &["show", &spec]).map_err(|e| format!("cannot read {spec}: {e}"))?;
String::from_utf8(bytes).map_err(|_| format!("{spec} is not UTF-8"))
}
pub fn default_branch(root: &Path) -> Option<String> {
if let Ok(name) = text(
root,
&["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
) && let Some(branch) = name.trim().strip_prefix("refs/remotes/")
{
return Some(branch.to_owned());
}
["origin/main", "origin/master", "main", "master"]
.into_iter()
.find(|candidate| {
git(
root,
&["rev-parse", "--verify", &format!("{candidate}^{{commit}}")],
)
.is_ok()
})
.map(str::to_owned)
}
pub fn automatic(root: &Path) -> Range {
let dirty = |arguments: &[&str]| {
text(root, arguments).is_ok_and(|out| {
out.lines().any(|path| {
!path.trim().is_empty() && !path.split('/').any(|segment| segment.starts_with('.'))
})
})
};
if dirty(&["diff", "--name-only"]) || dirty(&["ls-files", "--others", "--exclude-standard"]) {
return Range::Unstaged;
}
if dirty(&["diff", "--cached", "--name-only"]) {
return Range::Staged;
}
match default_branch(root) {
Some(branch) => Range::Base(branch),
None => Range::Unstaged,
}
}
pub fn is_repository(root: &Path) -> bool {
git(root, &["rev-parse", "--git-dir"]).is_ok()
}
pub fn head(root: &Path) -> Option<String> {
text(root, &["rev-parse", "HEAD"])
.ok()
.map(|s| s.trim().to_owned())
}
fn merge_base(root: &Path, reference: &str) -> Result<String, String> {
text(root, &["merge-base", reference, "HEAD"])
.map(|s| s.trim().to_owned())
.map_err(|error| format!(
"{error}\nCould not find where this branch left {reference}. A shallow checkout has \
no merge base: fetch with enough history (actions/checkout uses fetch-depth: 0), and \
make sure {reference} exists locally."
))
}
fn selector<'a>(range: &'a Range, resolved: &'a str) -> Vec<&'a str> {
match range {
Range::Unstaged => vec![],
Range::Staged => vec!["--cached"],
Range::Base(_) => vec![resolved],
}
}
fn prefix(root: &Path) -> Option<String> {
let top = text(root, &["rev-parse", "--show-toplevel"]).ok()?;
let top = std::fs::canonicalize(top.trim()).ok()?;
let here = std::fs::canonicalize(root).ok()?;
let rest = here
.strip_prefix(&top)
.ok()?
.to_string_lossy()
.replace('\\', "/");
(!rest.is_empty()).then(|| format!("{rest}/"))
}
fn patch(root: &Path, range: &Range, resolved: &str, path: &str) -> String {
let mut arguments = vec!["diff", "-U3"];
arguments.extend(selector(range, resolved));
arguments.extend(["--", path]);
text(root, &arguments).unwrap_or_default()
}
fn name_status(root: &Path, range: &Range, resolved: &str) -> Result<Vec<(Kind, String)>, String> {
let mut arguments = vec!["diff", "--name-status", "--no-renames", "-z"];
arguments.extend(selector(range, resolved));
let raw = text(root, &arguments)?;
let mut fields = raw.split('\0').filter(|s| !s.is_empty());
let mut out = Vec::new();
while let (Some(status), Some(path)) = (fields.next(), fields.next()) {
let kind = match status.chars().next() {
Some('A') => Kind::Added,
Some('D') => Kind::Deleted,
_ => Kind::Modified,
};
out.push((kind, path.to_owned()));
}
Ok(out)
}
pub fn collect(root: &Path, range: &Range, only: &[PathBuf]) -> Result<Vec<Change>, String> {
if !is_repository(root) {
return Err("quality patch needs a Git repository; run it inside one".into());
}
let resolved = match range {
Range::Base(reference) => merge_base(root, reference)?,
_ => String::new(),
};
let prefix = prefix(root);
let relocate = |path: String| -> Option<String> {
match prefix.as_ref() {
Some(prefix) => path.strip_prefix(prefix).map(str::to_owned),
None => Some(path),
}
};
let wanted = |path: &str| {
only.is_empty()
|| only.iter().any(|p| {
let p = p.to_string_lossy().trim_end_matches('/').to_owned();
path == p || path.starts_with(&format!("{p}/"))
})
};
let mut listed = name_status(root, range, &resolved)?;
if *range != Range::Staged {
for path in text(root, &["ls-files", "--others", "--exclude-standard", "-z"])?
.split('\0')
.filter(|s| !s.is_empty())
{
if path.split('/').any(|segment| segment.starts_with('.')) {
continue;
}
listed.push((Kind::Added, path.to_owned()));
}
}
let mut out = Vec::new();
for (kind, path) in listed {
let Some(path) = relocate(path) else { continue };
if !wanted(&path) {
continue;
}
let (before_rev, after_rev) = match range {
Range::Unstaged => ("", None),
Range::Staged => ("HEAD", Some("")),
Range::Base(_) => (resolved.as_str(), None),
};
let repository_path = match prefix.as_ref() {
Some(prefix) => format!("{prefix}{path}"),
None => path.clone(),
};
let before = if kind == Kind::Added {
String::new()
} else {
blob(root, before_rev, &repository_path)?
};
let after = match (kind, after_rev) {
(Kind::Deleted, _) => String::new(),
(_, Some(revision)) => blob(root, revision, &repository_path)?,
(_, None) => std::fs::read_to_string(root.join(&path)).unwrap_or_default(),
};
let patch = patch(root, range, &resolved, &repository_path);
out.push(Change {
path,
kind,
before,
after,
patch,
});
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out.dedup_by(|a, b| a.path == b.path);
Ok(out)
}
pub fn first_added_line(patch: &str) -> Option<u32> {
for line in patch.lines() {
let Some(rest) = line.strip_prefix("@@ ") else {
continue;
};
let after = rest.split(" +").nth(1)?;
let number = after.split([',', ' ', '@']).next()?.parse::<u32>().ok()?;
return Some(number.max(1));
}
None
}