use std::path::{Path, PathBuf};
use std::process::Command;
use spec_spine_core::delta;
use spec_spine_types::{DeltaCommits, DeltaReport, Error, verdict::Verdict, verdict::verb};
use crate::cmd_couple::{changed_path_names, merge_base};
use crate::load_repo_config;
use crate::out;
pub struct DeltaArgs {
pub base: String,
pub head: String,
pub json: bool,
}
pub fn run(repo: &Path, args: &DeltaArgs) -> Result<u8, Error> {
let commits = DeltaCommits {
base: resolve_commit(repo, &args.base)?,
merge_base: merge_base(repo, &args.base, &args.head)?,
head: resolve_commit(repo, &args.head)?,
};
let changed = changed_path_names(repo, &commits.merge_base, &commits.head)?;
let trees = TempTrees::create()?;
export_tree(
repo,
&commits.merge_base,
&trees.root.join("base.index"),
&trees.base,
)?;
export_tree(
repo,
&commits.head,
&trees.root.join("head.index"),
&trees.head,
)?;
let cfg = load_repo_config(&trees.base)?;
let report = delta(&cfg, &trees.base, &trees.head, &changed, &commits)?;
drop(trees);
if args.json {
let value = serde_json::to_value(&report).map_err(|e| Error::Schema(e.to_string()))?;
out::verdict(&Verdict::report(verb::DELTA, 0, value))?;
} else {
render(&report);
}
Ok(0)
}
fn render(report: &DeltaReport) {
outln!(
"spec-spine delta: {} path(s) changed, classified under the base's rules (merge base {})",
report.changes.len(),
short(&report.merge_base)
);
let width = report
.changes
.iter()
.map(|c| c.path.chars().count())
.max()
.unwrap_or(0);
for change in &report.changes {
let classes: Vec<String> = change.classes.iter().map(|c| class_token(*c)).collect();
outln!(
" {:<width$} {}",
change.path,
classes.join(", "),
width = width
);
}
if report.prior_policy.required {
let classes: Vec<String> = report
.prior_policy
.classes
.iter()
.map(|c| class_token(*c))
.collect();
outln!(
"prior policy: REQUIRED for {} (judge these under the base revision's policy)",
classes.join(", ")
);
} else {
outln!(
"prior policy: not required. This means only that no structural class changed; \
it does not mean the change is safe, correct or approved."
);
}
}
fn class_token(class: spec_spine_types::DeltaClass) -> String {
serde_json::to_value(class)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default()
}
fn short(commit: &str) -> &str {
commit.get(..12).unwrap_or(commit)
}
fn resolve_commit(repo: &Path, rev: &str) -> Result<String, Error> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "--verify", "--quiet", "--end-of-options"])
.arg(format!("{rev}^{{commit}}"))
.output()
.map_err(|e| Error::Io(format!("spawn git rev-parse: {e}")))?;
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !out.status.success() || id.is_empty() {
return Err(Error::Io(format!("git: '{rev}' does not name a commit")));
}
Ok(id)
}
fn export_tree(repo: &Path, commit: &str, index_file: &Path, dest: &Path) -> Result<(), Error> {
let git = |args: &[&str]| -> Result<(), Error> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.env("GIT_INDEX_FILE", index_file)
.output()
.map_err(|e| Error::Io(format!("spawn git {}: {e}", args[0])))?;
if out.status.success() {
Ok(())
} else {
Err(Error::Io(format!(
"git {} exited {:?}: {}",
args[0],
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
)))
}
};
git(&["read-tree", "--end-of-options", commit])?;
let prefix = format!("--prefix={}/", dest.display());
git(&["checkout-index", "--all", "--force", &prefix])
}
struct TempTrees {
root: PathBuf,
base: PathBuf,
head: PathBuf,
}
impl TempTrees {
fn create() -> Result<Self, Error> {
let parent = std::env::temp_dir();
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
for attempt in 0..16u32 {
let root = parent.join(format!("spec-spine-delta-{pid}-{nanos}-{attempt}"));
match std::fs::create_dir(&root) {
Ok(()) => {
let trees = TempTrees {
base: root.join("base"),
head: root.join("head"),
root,
};
for dir in [&trees.base, &trees.head] {
std::fs::create_dir(dir)
.map_err(|e| Error::Io(format!("create {}: {e}", dir.display())))?;
}
return Ok(trees);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => {
return Err(Error::Io(format!("create {}: {e}", root.display())));
}
}
}
Err(Error::Io(format!(
"could not create a temporary directory under {}",
parent.display()
)))
}
}
impl Drop for TempTrees {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}