use std::io::BufRead;
use std::sync::OnceLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushRef {
pub local_ref: String,
pub local_oid: String,
pub remote_ref: String,
pub remote_oid: String,
}
#[derive(Default)]
pub struct PushRefs(OnceLock<Vec<PushRef>>);
impl PushRefs {
pub fn get(&self) -> &[PushRef] {
self.0.get_or_init(|| parse(std::io::stdin().lock()))
}
pub fn preloaded(refs: Vec<PushRef>) -> PushRefs {
let cell = OnceLock::new();
let _ = cell.set(refs);
PushRefs(cell)
}
}
pub fn synthetic_from_upstream() -> Result<PushRef, String> {
let Some(upstream) =
crate::git::stdout(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
else {
return Err(
"no upstream configured for the current branch — nothing has been \
pushed yet, so there is nothing to diff against"
.to_string(),
);
};
let Some(local_oid) = crate::git::stdout(&["rev-parse", "HEAD"]) else {
return Err("could not resolve HEAD".to_string());
};
let Some(remote_oid) = crate::git::stdout(&["rev-parse", "@{u}"]) else {
return Err(format!("could not resolve upstream {upstream}"));
};
let local_ref =
crate::git::stdout(&["symbolic-ref", "-q", "HEAD"]).unwrap_or_else(|| "HEAD".to_string());
Ok(PushRef {
local_ref: local_ref.clone(),
local_oid,
remote_ref: local_ref,
remote_oid,
})
}
pub fn parse<R: BufRead>(r: R) -> Vec<PushRef> {
r.lines()
.map_while(Result::ok)
.filter_map(|line| {
let mut f = line.split_whitespace();
let (a, b, c, d) = (f.next()?, f.next()?, f.next()?, f.next()?);
Some(PushRef {
local_ref: a.to_owned(),
local_oid: b.to_owned(),
remote_ref: c.to_owned(),
remote_oid: d.to_owned(),
})
})
.collect()
}
pub fn changed_files(refs: &[PushRef]) -> Vec<String> {
let zero = crate::git::stdout(&["hash-object", "--stdin"])
.map(|h| "0".repeat(h.len()))
.unwrap_or_else(|| "0".repeat(40));
let mut changed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for r in refs {
changed.extend(changed_files_for(r, &zero));
}
changed.into_iter().collect()
}
pub fn changed_files_for(r: &PushRef, zero: &str) -> Vec<String> {
if r.local_oid == zero {
return Vec::new(); }
if r.remote_oid == zero {
let commits = crate::git::stdout(&["rev-list", &r.local_oid, "--not", "--remotes"]);
return match commits {
Some(commits) if !commits.is_empty() => diff_tree_stdin(&commits),
_ => diff_tree_stdin(&r.local_oid),
};
}
range_changed_files(&r.remote_oid, &r.local_oid)
}
fn range_changed_files(remote_oid: &str, local_oid: &str) -> Vec<String> {
let range = format!("{remote_oid}..{local_oid}");
let Some(commits) = crate::git::stdout(&["rev-list", &range]) else {
return Vec::new();
};
if commits.is_empty() {
return Vec::new();
}
diff_tree_stdin(&commits)
}
fn diff_tree_stdin(commits: &str) -> Vec<String> {
crate::git::stdout_piped_raw(
&[
"diff-tree",
"--no-commit-id",
"--name-only",
"-r",
"-m",
"-z",
"--stdin",
],
&format!("{}\n", commits.trim_end()),
)
.map(|raw| crate::git::split_nul_paths(&raw))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_four_fields() {
let got = parse(&b"refs/heads/x aaa refs/heads/y bbb\n"[..]);
assert_eq!(got.len(), 1);
assert_eq!(got[0].local_ref, "refs/heads/x");
assert_eq!(got[0].remote_ref, "refs/heads/y");
assert_eq!(got[0].remote_oid, "bbb");
}
#[test]
fn several_refs_and_junk_lines() {
let got = parse(&b"a 1 b 2\ngarbage\n\nc 3 d 4\n"[..]);
assert_eq!(got.len(), 2, "short lines are skipped, not fatal");
assert_eq!(got[1].local_ref, "c");
}
#[test]
fn empty_stdin_is_no_refs_not_an_error() {
assert!(parse(&b""[..]).is_empty());
}
#[test]
fn a_non_ascii_path_survives_the_diff_tree_parse() {
let mut raw = "src/é.ts".as_bytes().to_vec();
raw.push(0);
raw.extend_from_slice(b"src/plain.ts\0");
let got = crate::git::split_nul_paths(&raw);
assert_eq!(
got,
vec!["src/é.ts".to_string(), "src/plain.ts".to_string()]
);
assert!(
got[0].ends_with(".ts"),
"the quoted form ends with a quote, not an extension, and is why \
a scope-gated check never fired: {:?}",
got[0]
);
}
}