use std::path::{Path, PathBuf};
use callisto_model::{CommandRunner, Package};
use callisto_vcs::{GitAccess, GitDataSource};
use crate::error::GraphError;
use crate::tags::TagIndex;
pub fn package_paths(pkg: &Package) -> Vec<PathBuf> {
let mut set = std::collections::BTreeSet::new();
for m in &pkg.manifests {
if let Some(parent) = m.path.parent() {
if parent.as_os_str().is_empty() {
set.insert(PathBuf::from("."));
} else {
set.insert(parent.to_path_buf());
}
}
}
set.into_iter().collect()
}
pub fn changed_since_last_tag<R: CommandRunner>(
runner: &R,
root: &Path,
pkg: &Package,
tags: &TagIndex,
git: &GitAccess<'_>,
) -> Result<bool, GraphError> {
let Some(last) = tags.last_tag(&pkg.id) else {
return Ok(true);
};
let qualified = format!("refs/tags/{}", last.name.as_str());
if let Ok(commits) = git.commits_since(Some(&qualified), &[]) {
if !commits.is_empty() {
return Ok(true);
}
}
let paths = package_paths(pkg);
let mut args = vec!["diff", "--quiet", qualified.as_str(), "--"];
let path_strs: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
for p in &path_strs {
args.push(p);
}
let output = runner.run("git", &args, root)?;
Ok(!output.success())
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use callisto_model::{CommandError, CommandOutput, DepEdge, ManifestDecl, ManifestFormat, ManifestRole, PackageId};
use super::*;
use crate::resolver::DependencyResolver;
fn make_pkg(name: &str) -> Package {
let manifest = ManifestDecl::new(
format!("{name}/Cargo.toml"),
ManifestRole::Canonical,
ManifestFormat::CargoToml,
)
.unwrap();
Package {
id: PackageId::parse(name).unwrap(),
manifests: vec![manifest],
changelog: None,
release_trigger: callisto_model::ReleaseTrigger::Changeset,
publish_to: Vec::new(),
tag_template: None,
}
}
struct FixedGraph {
pkgs: Vec<Package>,
}
impl DependencyResolver for FixedGraph {
fn packages(&self) -> impl Iterator<Item = &Package> {
self.pkgs.iter()
}
fn dependencies_of(&self, _id: &PackageId) -> impl Iterator<Item = &DepEdge> {
std::iter::empty()
}
fn dependents_of(&self, _id: &PackageId) -> impl Iterator<Item = &DepEdge> {
std::iter::empty()
}
}
fn non_repo_dir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
assert!(
callisto_vcs::GitRepository::discover(dir.path()).is_err(),
"test fixture must not be discoverable as a Git repo"
);
dir
}
fn tag_index_with_tag(dir: &std::path::Path, pkg_name: &str, tag: &str) -> TagIndex {
struct TagListRunner(String);
impl CommandRunner for TagListRunner {
fn run(&self, program: &str, args: &[&str], _cwd: &Path) -> Result<CommandOutput, CommandError> {
assert_eq!(program, "git");
assert_eq!(args, ["tag", "--list"]);
Ok(CommandOutput {
exit_code: Some(0),
stdout: self.0.clone(),
stderr: String::new(),
})
}
}
let graph = FixedGraph {
pkgs: vec![make_pkg(pkg_name)],
};
let cfg = crate::config::load(dir).unwrap();
let runner = TagListRunner(tag.to_string());
let git = GitAccess::discover(dir, &runner);
TagIndex::build(&git, &graph, &cfg).unwrap()
}
struct RoutingRunner {
log_calls: AtomicUsize,
diff_calls: AtomicUsize,
log_stdout: String,
diff_exit_code: i32,
last_log_args: std::sync::Mutex<Vec<String>>,
last_diff_args: std::sync::Mutex<Vec<String>>,
}
impl CommandRunner for RoutingRunner {
fn run(&self, program: &str, args: &[&str], _cwd: &Path) -> Result<CommandOutput, CommandError> {
assert_eq!(program, "git");
match args.first() {
Some(&"log") => {
self.log_calls.fetch_add(1, Ordering::SeqCst);
*self.last_log_args.lock().unwrap() = args.iter().map(|s| s.to_string()).collect();
Ok(CommandOutput {
exit_code: Some(0),
stdout: self.log_stdout.clone(),
stderr: String::new(),
})
}
Some(&"diff") => {
self.diff_calls.fetch_add(1, Ordering::SeqCst);
*self.last_diff_args.lock().unwrap() = args.iter().map(|s| s.to_string()).collect();
Ok(CommandOutput {
exit_code: Some(self.diff_exit_code),
stdout: String::new(),
stderr: String::new(),
})
}
other => panic!("unexpected git subcommand: {other:?}"),
}
}
}
fn routing_runner(log_stdout: String, diff_exit_code: i32) -> RoutingRunner {
RoutingRunner {
log_calls: AtomicUsize::new(0),
diff_calls: AtomicUsize::new(0),
log_stdout,
diff_exit_code,
last_log_args: std::sync::Mutex::new(Vec::new()),
last_diff_args: std::sync::Mutex::new(Vec::new()),
}
}
#[test]
fn returns_true_immediately_when_package_has_no_last_tag() {
let dir = non_repo_dir();
let pkg = make_pkg("pkg-a");
let tags = TagIndex::empty();
let runner = routing_runner(String::new(), 0);
let git = GitAccess::discover(dir.path(), &runner);
let changed = changed_since_last_tag(&runner, dir.path(), &pkg, &tags, &git).unwrap();
assert!(changed, "a package with no last tag must count as changed");
assert_eq!(
runner.log_calls.load(Ordering::SeqCst),
0,
"must short-circuit before shelling any git command"
);
assert_eq!(runner.diff_calls.load(Ordering::SeqCst), 0);
}
#[test]
fn short_circuits_true_when_commits_since_finds_commits_no_diff_check_needed() {
let dir = non_repo_dir();
let pkg = make_pkg("pkg-a");
let tags = tag_index_with_tag(dir.path(), "pkg-a", "pkg-a@1.0.0");
let sha = "a".repeat(40);
let log_stdout = format!("\u{1e}{sha}\u{1f}feat: something\n");
let runner = routing_runner(log_stdout, 0);
let git = GitAccess::discover(dir.path(), &runner);
let changed = changed_since_last_tag(&runner, dir.path(), &pkg, &tags, &git).unwrap();
assert!(changed);
assert_eq!(runner.log_calls.load(Ordering::SeqCst), 1);
assert_eq!(
runner.diff_calls.load(Ordering::SeqCst),
0,
"commits_since already found commits; diff --quiet must not run"
);
assert!(
runner
.last_log_args
.lock()
.unwrap()
.iter()
.any(|a| a == "refs/tags/pkg-a@1.0.0..HEAD"),
"the tag must be shelled as a fully-qualified refs/tags/ ref, not a bare name that \
a maliciously-named tag could get misread as a `git log` flag, got: {:?}",
runner.last_log_args.lock().unwrap()
);
}
#[test]
fn falls_back_to_diff_quiet_when_commits_since_is_empty() {
let dir = non_repo_dir();
let pkg = make_pkg("pkg-a");
let tags = tag_index_with_tag(dir.path(), "pkg-a", "pkg-a@1.0.0");
let runner = routing_runner(String::new(), 1); let git = GitAccess::discover(dir.path(), &runner);
let changed = changed_since_last_tag(&runner, dir.path(), &pkg, &tags, &git).unwrap();
assert!(changed, "non-zero diff --quiet exit must mean changed");
assert_eq!(runner.log_calls.load(Ordering::SeqCst), 1);
assert_eq!(runner.diff_calls.load(Ordering::SeqCst), 1);
assert!(
runner
.last_diff_args
.lock()
.unwrap()
.iter()
.any(|a| a == "refs/tags/pkg-a@1.0.0"),
"the tag must be shelled as a fully-qualified refs/tags/ ref in the `git diff` \
positional too, got: {:?}",
runner.last_diff_args.lock().unwrap()
);
}
#[test]
fn diff_quiet_success_exit_means_unchanged() {
let dir = non_repo_dir();
let pkg = make_pkg("pkg-a");
let tags = tag_index_with_tag(dir.path(), "pkg-a", "pkg-a@1.0.0");
let runner = routing_runner(String::new(), 0); let git = GitAccess::discover(dir.path(), &runner);
let changed = changed_since_last_tag(&runner, dir.path(), &pkg, &tags, &git).unwrap();
assert!(!changed);
}
#[test]
fn shared_git_access_is_reusable_across_multiple_packages() {
let dir = non_repo_dir();
let pkg_a = make_pkg("pkg-a");
let pkg_b = make_pkg("pkg-b");
let tags_a = tag_index_with_tag(dir.path(), "pkg-a", "pkg-a@1.0.0");
let tags_b = tag_index_with_tag(dir.path(), "pkg-b", "pkg-b@1.0.0");
let runner = routing_runner(String::new(), 0);
let git = GitAccess::discover(dir.path(), &runner);
changed_since_last_tag(&runner, dir.path(), &pkg_a, &tags_a, &git).unwrap();
changed_since_last_tag(&runner, dir.path(), &pkg_b, &tags_b, &git).unwrap();
assert_eq!(
runner.log_calls.load(Ordering::SeqCst),
2,
"exactly one git log per package"
);
assert_eq!(
runner.diff_calls.load(Ordering::SeqCst),
2,
"exactly one git diff per package"
);
}
}