use std::path::{Path, PathBuf};
use std::process::Command;
use spec_spine_core::couple::spec_id_for_spec_md_path;
use spec_spine_core::{
CoupleReport, DiffFile, DiffInput, FileContents, couple, dependency_only_waiver,
is_bypassed_path, load_committed_index, parse_waiver,
};
use spec_spine_types::{Config, Error, LineSpan, Verdict, Violation, verdict::verb};
use crate::load_repo_config;
use crate::out;
pub struct CoupleArgs {
pub base: String,
pub head: String,
pub pr_body: Option<PathBuf>,
pub paths_from: Option<PathBuf>,
pub include_uncommitted: bool,
pub json: bool,
}
pub fn run(repo: &Path, args: &CoupleArgs) -> Result<u8, Error> {
let cfg = load_repo_config(repo)?;
let diff = build_diff_input(repo, args)?;
let body = read_pr_body(args)?;
let mut waiver = parse_waiver(&cfg, &body);
let mut auto_waived = false;
if waiver.is_none() && cfg.coupling.auto_waive_dependency_only && args.paths_from.is_none() {
waiver = try_dependency_only_waiver(repo, &cfg, args, &diff)?;
auto_waived = waiver.is_some();
}
let report = couple(&cfg, repo, &diff, waiver.as_ref())?;
if args.json {
let value = serde_json::to_value(&report).map_err(|e| Error::Schema(e.to_string()))?;
let code = report.exit_code();
out::verdict(&Verdict::report(verb::COUPLE, code, value))?;
return Ok(code);
}
if report.has_blocking_drift() {
let unclaimed = report
.violations
.iter()
.filter(|v| v.code == "C-002")
.count();
let drift = report.violations.len() - unclaimed;
if unclaimed == 0 {
eprintln!(
"spec-spine couple: {drift} drift violation(s): a changed path lacks an authoring edit to an owning spec.\n"
);
} else {
eprintln!(
"spec-spine couple: {} violation(s): {drift} drift (C-001), {unclaimed} unclaimed (C-002, require_ownership is on).\n",
report.violations.len()
);
}
for v in &report.violations {
eprintln!(" {} {}", v.code, v.message);
}
eprint!("{}", resolution_footer(&cfg, &report, &diff));
} else if let Some(reason) = &report.waiver {
outln!(
"spec-spine couple: {} violation(s) {}, reason: {reason}",
report.violations.len(),
if auto_waived { "auto-waived" } else { "waived" }
);
for v in &report.violations {
outln!(" {} (waived)", v.message);
}
} else {
outln!(
"spec-spine couple: OK: {} path(s) checked, no drift.",
report.checked_paths
);
}
Ok(report.exit_code())
}
fn resolution_footer(cfg: &Config, report: &CoupleReport, diff: &DiffInput) -> String {
let drift: Vec<&Violation> = report
.violations
.iter()
.filter(|v| v.code == "C-001")
.collect();
let unclaimed = report.violations.len() - drift.len();
let keyword = &cfg.coupling.waiver_keyword;
if drift.is_empty() {
return format!(
"\nResolve by editing an owning spec's spec.md (C-001), claiming the path in a spec's owning edge (C-002), or add a '{keyword}' line to the PR body.\n"
);
}
let mut f = String::from("\nResolve, in the order an author should consider them");
if unclaimed > 0 {
f.push_str(" (1-3 answer C-001; 4 answers C-002)");
}
f.push_str(":\n\n");
f.push_str(
" 1. Edit the owning spec's spec.md. The right door when the spec that owns\n\
\x20 the path is the one you are authoring.\n\n",
);
f.push_str(
" 2. Declare an `extends` edge in your OWN spec, naming the owning spec\n\
\x20 and the unit you touched. That makes your spec a legitimate owner of\n\
\x20 the unit, so the gate clears on the next run. It amends nobody and\n\
\x20 needs no waiver.\n\n",
);
f.push_str(&extends_guidance(cfg, &drift, diff));
f.push_str(&format!(
" 3. Add a '{keyword} <reason>' line to the PR body. A waiver is a\n\
\x20 human instrument: it needs explicit human approval, and is not a\n\
\x20 flag an unattended session sets for itself.\n"
));
if unclaimed > 0 {
f.push_str(
"\n 4. For the unclaimed path(s) above, claim the path in a spec's owning\n\
\x20 edge.\n",
);
}
f
}
fn extends_guidance(cfg: &Config, drift: &[&Violation], diff: &DiffInput) -> String {
let specs_dir = cfg.layout.specs_dir.as_str();
let edited: Vec<&str> = diff
.files
.iter()
.filter_map(|f| spec_id_for_spec_md_path(specs_dir, &f.path))
.collect();
let Some(id) = edited.first().filter(|_| edited.len() == 1) else {
return concat!(
" In your spec's frontmatter:\n\n",
" extends:\n",
" - { spec: \"<owning-spec-id>\", unit: \"<path>\", nature: additive }\n\n",
)
.to_string();
};
let mut s = format!(
" spec {id} is the only spec.md edited in this diff, and owns none of\n\
\x20 the paths above. Cross into the owning territory by declaring, in\n\
\x20 {}:\n\n\
\x20 extends:\n",
spec_md_rel(specs_dir, id)
);
for v in drift {
let (Some(path), Some(owner)) = (v.path.as_deref(), v.owners.first()) else {
continue;
};
s.push_str(&format!(
" - {{ spec: \"{owner}\", unit: \"{path}\", nature: additive }}\n"
));
}
s.push_str(
"\n A suggestion, not an instruction, and not the only correct form:\n\
\x20 where a path has several owners any one of them clears it, a narrower\n\
\x20 section or symbol unit is available if you want the claim tighter,\n\
\x20 and `nature` is a free-text hint (`superseding` describes a crossing\n\
\x20 that replaces behavior rather than adding to it). If you did not mean\n\
\x20 to touch the path, revert the touch and declare nothing.\n\n",
);
s
}
fn spec_md_rel(specs_dir: &str, id: &str) -> String {
format!("{}/{id}/spec.md", specs_dir.trim_end_matches('/'))
}
fn build_diff_input(repo: &Path, args: &CoupleArgs) -> Result<DiffInput, Error> {
if let Some(path) = &args.paths_from {
if args.include_uncommitted {
return Err(Error::Config(
"--include-uncommitted unions the working tree into a git diff, and \
--paths-from replaces that diff with a path list; pass one or the other"
.to_string(),
));
}
let text = std::fs::read_to_string(path)
.map_err(|e| Error::Io(format!("read --paths-from {}: {e}", path.display())))?;
let files = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|p| DiffFile {
path: p.to_string(),
hunks: Vec::new(),
deleted: false,
})
.collect();
return Ok(DiffInput { files });
}
let raw = run_git_diff(repo, &[&format!("{}...{}", args.base, args.head)])?;
let mut diff = parse_unified_diff(&raw);
let range = format!("{}...{}", args.base, args.head);
let statuses = changed_path_statuses(repo, &[range.as_str()])?;
union_name_statuses(&mut diff, statuses);
if args.include_uncommitted {
let head_oid = rev_parse(repo, "HEAD")?;
if rev_parse(repo, &args.head)? != head_oid {
return Err(Error::Config(format!(
"--include-uncommitted compares the working tree with HEAD, so it cannot be \
combined with --head {}, which resolves to a different commit",
args.head
)));
}
let wt_raw = run_git_diff(repo, &["HEAD"])?;
let wt = parse_unified_diff(&wt_raw);
union_diff(&mut diff, wt);
union_name_statuses(&mut diff, changed_path_statuses(repo, &["HEAD"])?);
}
Ok(diff)
}
fn rev_parse(repo: &Path, rev: &str) -> Result<String, Error> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "--verify", "--end-of-options"])
.arg(rev)
.output()
.map_err(|e| Error::Io(format!("spawn git rev-parse: {e}")))?;
if !out.status.success() {
return Err(Error::Io(format!(
"git rev-parse {rev} exited {:?}: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn union_diff(diff: &mut DiffInput, later: DiffInput) {
for f in later.files {
match diff.files.iter_mut().find(|e| e.path == f.path) {
Some(existing) => {
existing.hunks.extend(f.hunks);
existing.deleted = f.deleted;
}
None => diff.files.push(f),
}
}
diff.files.sort_by(|a, b| a.path.cmp(&b.path));
}
fn union_name_statuses(diff: &mut DiffInput, statuses: Vec<(String, String)>) {
let mut known: std::collections::BTreeSet<String> =
diff.files.iter().map(|f| f.path.clone()).collect();
let mut added = false;
for (status, path) in statuses {
if !known.insert(path.clone()) {
continue;
}
diff.files.push(DiffFile {
deleted: status == "D",
path,
hunks: Vec::new(),
});
added = true;
}
if added {
diff.files.sort_by(|a, b| a.path.cmp(&b.path));
}
}
fn try_dependency_only_waiver(
repo: &Path,
cfg: &Config,
args: &CoupleArgs,
diff: &DiffInput,
) -> Result<Option<spec_spine_core::Waiver>, Error> {
let Ok(index) = load_committed_index(cfg, repo) else {
return Ok(None);
};
let candidates: Vec<&DiffFile> = diff
.files
.iter()
.filter(|f| !is_bypassed_path(cfg, &index, &f.path))
.collect();
if candidates.is_empty()
|| !candidates
.iter()
.all(|f| spec_spine_core::is_dependency_manifest(&f.path))
{
return Ok(None);
}
let Some(merge_base) = git_merge_base(repo, &args.base, &args.head) else {
return Ok(None);
};
let mut files: Vec<FileContents> = Vec::with_capacity(candidates.len());
for f in &candidates {
files.push(FileContents {
path: f.path.clone(),
base: git_show(repo, &merge_base, &f.path),
head: git_show(repo, &args.head, &f.path),
});
}
Ok(dependency_only_waiver(&files))
}
fn git_merge_base(repo: &Path, base: &str, head: &str) -> Option<String> {
merge_base(repo, base, head).ok()
}
pub(crate) fn merge_base(repo: &Path, base: &str, head: &str) -> Result<String, Error> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["merge-base", "--end-of-options", base, head])
.output()
.map_err(|e| Error::Io(format!("spawn git merge-base: {e}")))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(Error::Io(format!(
"git merge-base {base} {head} exited {:?}: {}",
out.status.code(),
stderr.trim()
)));
}
let rev = String::from_utf8_lossy(&out.stdout).trim().to_string();
if rev.is_empty() {
return Err(Error::Io(format!(
"git merge-base {base} {head} printed no commit"
)));
}
Ok(rev)
}
pub(crate) fn changed_path_names(repo: &Path, from: &str, to: &str) -> Result<Vec<String>, Error> {
Ok(changed_path_statuses(repo, &[from, to])?
.into_iter()
.map(|(_, path)| path)
.collect())
}
pub(crate) fn changed_path_statuses(
repo: &Path,
revs: &[&str],
) -> Result<Vec<(String, String)>, Error> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["-c", "core.quotepath=false"])
.args([
"diff",
"--name-status",
"-z",
"--no-renames",
"--end-of-options",
])
.args(revs)
.output()
.map_err(|e| Error::Io(format!("spawn git diff: {e}")))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(Error::Io(format!(
"git diff --name-status exited {:?}: {}",
out.status.code(),
stderr.trim()
)));
}
parse_name_status_z(&out.stdout)
}
fn parse_name_status_z(stdout: &[u8]) -> Result<Vec<(String, String)>, Error> {
let mut fields = stdout
.split(|b| *b == 0)
.map(|f| String::from_utf8_lossy(f).into_owned());
let mut entries = Vec::new();
while let Some(status) = fields.next() {
if status.is_empty() {
continue;
}
let two_paths = status.starts_with('R') || status.starts_with('C');
let mut path = fields.next();
if two_paths {
path = fields.next();
}
match path {
Some(path) if !path.is_empty() => entries.push((status, path)),
_ => {
return Err(Error::Io(format!(
"git diff --name-status: status {status:?} with no path"
)));
}
}
}
Ok(entries)
}
fn git_show(repo: &Path, rev: &str, path: &str) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["show", "--end-of-options", &format!("{rev}:{path}")])
.output()
.ok()?;
if !out.status.success() {
return None; }
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn run_git_diff(repo: &Path, revs: &[&str]) -> Result<String, Error> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["-c", "core.quotepath=false"])
.args([
"diff",
"--no-color",
"-U0",
"--no-renames",
"--end-of-options",
])
.args(revs)
.output()
.map_err(|e| Error::Io(format!("spawn git diff: {e}")))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(Error::Io(format!(
"git diff exited {:?}: {stderr}",
out.status.code()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn parse_unified_diff(diff_text: &str) -> DiffInput {
use std::collections::BTreeMap;
#[derive(Default)]
struct Entry {
hunks: Vec<LineSpan>,
deleted: bool,
}
let mut files: BTreeMap<String, Entry> = BTreeMap::new();
let mut current_path: Option<String> = None;
let mut minus_path: Option<String> = None;
for line in diff_text.lines() {
if let Some(rest) = line.strip_prefix("--- ") {
minus_path = strip_diff_prefix(rest.trim());
} else if let Some(rest) = line.strip_prefix("+++ ") {
let p = rest.trim();
let deleted = p == "/dev/null";
if deleted {
current_path = minus_path.clone();
} else {
current_path = strip_diff_prefix(p);
}
if let Some(path) = ¤t_path {
let entry = files.entry(path.clone()).or_default();
entry.deleted = deleted;
}
} else if line.starts_with("@@") {
if let Some(path) = ¤t_path {
if let Some(span) = parse_hunk_header(line) {
files.entry(path.clone()).or_default().hunks.push(span);
}
}
}
}
DiffInput {
files: files
.into_iter()
.map(|(path, entry)| DiffFile {
path,
hunks: entry.hunks,
deleted: entry.deleted,
})
.collect(),
}
}
fn strip_diff_prefix(p: &str) -> Option<String> {
if p == "/dev/null" {
return None;
}
Some(
p.strip_prefix("a/")
.or_else(|| p.strip_prefix("b/"))
.unwrap_or(p)
.to_string(),
)
}
fn parse_hunk_header(line: &str) -> Option<LineSpan> {
let after_at = line.strip_prefix("@@")?.trim_start();
let rest = after_at.strip_prefix('-')?;
let plus_pos = rest.find('+')?;
let new_part = rest[plus_pos + 1..].trim_start();
let new_range = new_part.split_whitespace().next()?;
let (start_s, count_s) = match new_range.split_once(',') {
Some((a, b)) => (a, b),
None => (new_range, "1"),
};
let start: usize = start_s.parse().ok()?;
let count: usize = count_s.parse().ok()?;
if start == 0 {
return None;
}
let count = count.max(1);
Some(LineSpan::new(start, start + count - 1))
}
fn read_pr_body(args: &CoupleArgs) -> Result<String, Error> {
if let Some(path) = &args.pr_body {
std::fs::read_to_string(path)
.map_err(|e| Error::Io(format!("read --pr-body {}: {e}", path.display())))
} else if let Ok(s) = std::env::var("SPEC_SPINE_PR_BODY") {
Ok(s)
} else {
Ok(String::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_modification_hunks_to_inclusive_spans() {
let diff = "diff --git a/Makefile b/Makefile\n\
--- a/Makefile\n\
+++ b/Makefile\n\
@@ -10,2 +10,3 @@ ctx\n\
@@ -50 +51,5 @@\n";
let d = parse_unified_diff(diff);
let f = d.files.iter().find(|f| f.path == "Makefile").unwrap();
assert_eq!(f.hunks, vec![LineSpan::new(10, 12), LineSpan::new(51, 55)]);
}
#[test]
fn deleted_file_is_whole_file_change() {
let diff = "diff --git a/gone.rs b/gone.rs\n\
deleted file mode 100644\n\
--- a/gone.rs\n\
+++ /dev/null\n\
@@ -1,5 +0,0 @@\n";
let d = parse_unified_diff(diff);
let f = d.files.iter().find(|f| f.path == "gone.rs").unwrap();
assert!(f.hunks.is_empty(), "deletion ⇒ whole-file (no hunks)");
assert!(f.deleted, "deletion is flagged for the ownership ratchet");
}
#[test]
fn modified_and_added_files_are_not_deleted() {
let diff = "diff --git a/Makefile b/Makefile\n\
--- a/Makefile\n\
+++ b/Makefile\n\
@@ -10,2 +10,3 @@ ctx\n\
diff --git a/new.rs b/new.rs\n\
new file mode 100644\n\
--- /dev/null\n\
+++ b/new.rs\n\
@@ -0,0 +1,3 @@\n";
let d = parse_unified_diff(diff);
assert!(d.files.iter().all(|f| !f.deleted), "{:?}", d.files);
}
#[test]
fn rename_under_no_renames_registers_both_paths() {
let diff = "diff --git a/old/mod.rs b/old/mod.rs\n\
deleted file mode 100644\n\
--- a/old/mod.rs\n\
+++ /dev/null\n\
@@ -1,3 +0,0 @@\n\
diff --git a/new/mod.rs b/new/mod.rs\n\
new file mode 100644\n\
--- /dev/null\n\
+++ b/new/mod.rs\n\
@@ -0,0 +1,3 @@\n";
let d = parse_unified_diff(diff);
assert!(
d.files.iter().any(|f| f.path == "old/mod.rs"),
"vacated path must be seen"
);
assert!(
d.files.iter().any(|f| f.path == "new/mod.rs"),
"new path must be seen"
);
}
#[test]
fn name_status_z_pairs_each_status_with_its_path() {
let out = b"M\0src/a.rs\0D\0gone.bin\0A\0path with\nnewline\0R100\0old.rs\0new.rs\0";
let entries = parse_name_status_z(out).unwrap();
assert_eq!(
entries,
vec![
("M".to_string(), "src/a.rs".to_string()),
("D".to_string(), "gone.bin".to_string()),
("A".to_string(), "path with\nnewline".to_string()),
("R100".to_string(), "new.rs".to_string()),
]
);
assert!(parse_name_status_z(b"").unwrap().is_empty());
assert!(
parse_name_status_z(b"M\0").is_err(),
"a status with no path"
);
}
#[test]
fn union_adds_headerless_paths_and_keeps_parsed_spans() {
let mut d = parse_unified_diff(
"diff --git a/lib.rs b/lib.rs\n\
--- a/lib.rs\n\
+++ b/lib.rs\n\
@@ -3 +3,2 @@\n",
);
union_name_statuses(
&mut d,
vec![
("M".into(), "run.sh".into()),
("M".into(), "lib.rs".into()),
("D".into(), "logo.bin".into()),
],
);
let paths: Vec<&str> = d.files.iter().map(|f| f.path.as_str()).collect();
assert_eq!(paths, vec!["lib.rs", "logo.bin", "run.sh"]);
let lib = &d.files[0];
assert_eq!(lib.hunks, vec![LineSpan::new(3, 4)], "spans survive");
assert!(!lib.deleted);
assert!(d.files[1].deleted && d.files[1].hunks.is_empty());
assert!(!d.files[2].deleted && d.files[2].hunks.is_empty());
}
#[test]
fn real_git_text_change_keeps_spans_through_the_union() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let git = |args: &[&str]| {
let out = Command::new("git")
.arg("-C")
.arg(root)
.args(["-c", "commit.gpgsign=false"])
.args(args)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
git(&["init", "-q"]);
std::fs::write(
root.join("Makefile"),
"top:\n\techo top\n\nbot:\n\techo bot\n",
)
.unwrap();
std::fs::write(root.join("logo.png"), b"\x89PNG\0\x01").unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "-m", "base"]);
std::fs::write(
root.join("Makefile"),
"top:\n\techo top\n\nbot:\n\techo bottom\n",
)
.unwrap();
std::fs::write(root.join("logo.png"), b"\x89PNG\0\x02").unwrap();
git(&["add", "-A"]);
git(&["update-index", "--chmod=+x", "Makefile"]);
git(&["commit", "-q", "-m", "head"]);
let args = CoupleArgs {
base: "HEAD~1".into(),
head: "HEAD".into(),
pr_body: None,
paths_from: None,
include_uncommitted: false,
json: false,
};
let d = build_diff_input(root, &args).unwrap();
let paths: Vec<&str> = d.files.iter().map(|f| f.path.as_str()).collect();
assert_eq!(paths, vec!["Makefile", "logo.png"]);
assert_eq!(d.files[0].hunks, vec![LineSpan::new(5, 5)], "span kept");
assert!(d.files[1].hunks.is_empty(), "binary is whole-file");
assert!(!d.files[0].deleted && !d.files[1].deleted);
}
}