use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use spec_spine_core::couple::spec_id_for_spec_md_path;
use spec_spine_core::{
CheckOutcome, CoupleReport, DiffFile, DiffInput, FileContents, PriorOwnership, PriorSnapshots,
WaiverDeclaration, WaiverInputs, WaiverSet, couple_snapshots_waived, dependency_only_waiver,
is_bypassed_path, load_committed_index, parse_waivers, prior_ownership_from_root, tree_config,
};
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 waiver_as_of: Option<String>,
pub waiver_uses: Vec<String>,
pub json: bool,
}
pub fn run(repo: &Path, args: &CoupleArgs) -> Result<u8, Error> {
let cfg = load_repo_config(repo)?;
let Segments {
diff,
worktree_deletions,
} = build_diff_input(repo, args)?;
let body = read_pr_body(args)?;
let mut waivers = parse_waivers(&cfg, &body);
let mut auto_waived = false;
if waivers.declarations.is_empty()
&& cfg.coupling.auto_waive_dependency_only
&& args.paths_from.is_none()
&& let Some(w) = try_dependency_only_waiver(repo, &cfg, args, &diff)?
{
waivers
.declarations
.push(WaiverDeclaration::unscoped(w.reason));
auto_waived = true;
}
let inputs = waiver_inputs(repo, args, &waivers)?;
let exports = PriorExports::build(repo, args, &diff, &worktree_deletions)?;
let prior = PriorSnapshots {
merge_base: exports.merge_base.as_ref(),
head_commit: exports.head_commit.as_ref(),
worktree_deletions,
};
let report = couple_snapshots_waived(&cfg, repo, &diff, &waivers, &inputs, &prior)?;
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);
}
let open: Vec<Violation> = report.uncleared().into_iter().cloned().collect();
if report.has_blocking_drift() {
let unclaimed = open.iter().filter(|v| v.code == "C-002").count();
let drift = open.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",
open.len()
);
}
for v in &open {
eprintln!(" {} {}", v.code, v.message);
}
if waivers_worth_reporting(&report) {
eprint!("{}", render_waivers(&report));
}
let unwaived = CoupleReport {
violations: open,
..report.clone()
};
eprint!("{}", resolution_footer(&cfg, &unwaived, &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);
}
if waivers_worth_reporting(&report) {
out!("{}", render_waivers(&report));
}
} else {
outln!(
"spec-spine couple: OK: {} path(s) checked, no drift.",
report.checked_paths
);
if waivers_worth_reporting(&report) {
out!("{}", render_waivers(&report));
}
}
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('/'))
}
pub(crate) struct Segments {
pub(crate) diff: DiffInput,
pub(crate) worktree_deletions: BTreeSet<String>,
}
fn build_diff_input(repo: &Path, args: &CoupleArgs) -> Result<Segments, 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(Segments {
diff: DiffInput { files },
worktree_deletions: BTreeSet::new(),
});
}
let raw =
run_git_diff(repo, &[&format!("{}...{}", args.base, args.head)]).map_err(unobtainable)?;
let mut diff = parse_unified_diff(&raw);
let mut worktree_deletions: BTreeSet<String> = BTreeSet::new();
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);
let wt_statuses = changed_path_statuses(repo, &["HEAD"])?;
let wt_paths: BTreeSet<String> = wt_statuses
.iter()
.filter(|(status, _)| status == "D")
.map(|(_, path)| path.clone())
.collect();
union_name_statuses(&mut diff, wt_statuses);
worktree_deletions = diff
.files
.iter()
.filter(|f| f.deleted && wt_paths.contains(&f.path))
.map(|f| f.path.clone())
.collect();
}
Ok(Segments {
diff,
worktree_deletions,
})
}
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 waiver_inputs(
repo: &Path,
args: &CoupleArgs,
waivers: &WaiverSet,
) -> Result<WaiverInputs, Error> {
let mut inputs = WaiverInputs {
as_of: args.waiver_as_of.clone(),
..WaiverInputs::default()
};
for entry in &args.waiver_uses {
let parsed = entry
.rsplit_once('=')
.and_then(|(id, n)| n.trim().parse::<u64>().ok().map(|n| (id.trim(), n)))
.filter(|(id, _)| !id.is_empty());
let Some((id, n)) = parsed else {
return Err(Error::Parse(format!(
"--waiver-uses '{entry}' is not <waiver id>=<count>"
)));
};
inputs.uses.insert(id.to_string(), n);
}
let sinces: BTreeSet<&str> = waivers
.declarations
.iter()
.filter_map(|d| d.since.as_deref())
.filter(|s| (4..=40).contains(&s.len()) && s.bytes().all(|c| c.is_ascii_hexdigit()))
.collect();
for since in sinces {
let status = Command::new("git")
.args([
"merge-base",
"--is-ancestor",
"--end-of-options",
since,
&args.head,
])
.current_dir(repo)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
match status.ok().and_then(|s| s.code()) {
Some(0) => {
inputs.ancestry.insert(since.to_string(), true);
}
Some(1) => {
inputs.ancestry.insert(since.to_string(), false);
}
_ => {}
}
}
Ok(inputs)
}
fn waivers_worth_reporting(report: &CoupleReport) -> bool {
!report.unattached_waiver_lines.is_empty()
|| report.waivers.len() > 1
|| report
.waivers
.iter()
.any(|w| w.scoped || !w.checks.is_empty())
}
fn render_waivers(report: &CoupleReport) -> String {
use std::fmt::Write as _;
let mut s = String::new();
for (n, w) in report.waivers.iter().enumerate() {
let scope = if w.scoped {
format!("scoped to {}", w.paths.join(", "))
} else {
"unscoped (clears every violation)".to_string()
};
let state = if w.effective { "effective" } else { "REFUSED" };
let _ = writeln!(
s,
" waiver {} {}: \"{}\", {scope}, {state}, cleared {}",
n + 1,
w.id,
w.reason,
w.clears.len()
);
for c in &w.clears {
let _ = writeln!(
s,
" clears {} {}",
c.code,
c.path.as_deref().unwrap_or("-")
);
}
for c in &w.checks {
let outcome = match c.outcome {
CheckOutcome::Satisfied => "satisfied",
CheckOutcome::Failed => "failed",
CheckOutcome::NotEvaluated => "not evaluated",
};
let input = c
.input
.as_deref()
.map(|i| format!(", input {i}"))
.unwrap_or_default();
let detail = c
.detail
.as_deref()
.map(|d| format!(": {d}"))
.unwrap_or_default();
let _ = writeln!(
s,
" {}: {outcome} (declared {}{input}){detail}",
c.check, c.declared
);
}
}
for line in &report.unattached_waiver_lines {
let _ = writeln!(s, " unattached waiver line, narrows nothing: {line}");
}
s
}
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())
}
}
pub(crate) struct PriorExports {
pub(crate) merge_base: Option<PriorOwnership>,
pub(crate) head_commit: Option<PriorOwnership>,
_root: Option<TempRoot>,
}
impl PriorExports {
fn build(
repo: &Path,
args: &CoupleArgs,
diff: &DiffInput,
worktree_deletions: &BTreeSet<String>,
) -> Result<Self, Error> {
let deleted: Vec<&DiffFile> = diff.files.iter().filter(|f| f.deleted).collect();
if deleted.is_empty() {
return Ok(PriorExports {
merge_base: None,
head_commit: None,
_root: None,
});
}
let needs_merge_base = deleted
.iter()
.any(|f| !worktree_deletions.contains(&f.path));
let needs_head_commit = deleted.iter().any(|f| worktree_deletions.contains(&f.path));
let root = TempRoot::create()?;
let mut exports = PriorExports {
merge_base: None,
head_commit: None,
_root: None,
};
if needs_merge_base {
let commit = merge_base(repo, &args.base, &args.head).map_err(unobtainable)?;
exports.merge_base = Some(snapshot_at(repo, &commit, &root, "merge-base")?);
}
if needs_head_commit {
exports.head_commit = Some(snapshot_at(repo, "HEAD", &root, "head-commit")?);
}
exports._root = Some(root);
Ok(exports)
}
}
fn snapshot_at(
repo: &Path,
commit: &str,
root: &TempRoot,
label: &str,
) -> Result<PriorOwnership, Error> {
let dest = root.0.join(label);
std::fs::create_dir(&dest).map_err(|e| Error::Io(format!("create {}: {e}", dest.display())))?;
crate::cmd_delta::export_tree(repo, commit, &root.0.join(format!("{label}.index")), &dest)
.map_err(|e| {
unobtainable(Error::Io(format!(
"could not export the {label} tree ({commit}): {e}"
)))
})?;
let cfg = tree_config(&dest).map_err(|e| {
Error::Parse(format!(
"the {label} snapshot's configuration could not be read, so this change's \
deletions cannot be judged: {e}. The snapshot is {commit}; repair that \
commit's spec-spine.toml and rebase rather than re-running."
))
})?;
let paths = tracked_paths(repo, commit)?;
let snapshot = prior_ownership_from_root(&cfg, &dest).map_err(|e| {
Error::Parse(format!(
"the {label} snapshot's corpus could not be compiled, so this change's \
deletions cannot be judged: {e}. A snapshot that could not be built has not \
answered; repair that commit's corpus and rebase rather than re-running."
))
})?;
Ok(snapshot.with_paths(paths))
}
fn tracked_paths(repo: &Path, commit: &str) -> Result<BTreeSet<String>, Error> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args([
"-c",
"core.quotepath=false",
"ls-tree",
"-r",
"--name-only",
"--full-tree",
"-z",
"--end-of-options",
commit,
])
.output()
.map_err(|e| Error::Io(format!("spawn git ls-tree: {e}")))?;
if !out.status.success() {
return Err(unobtainable(Error::Io(format!(
"git ls-tree {commit} exited {:?}: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
))));
}
Ok(String::from_utf8_lossy(&out.stdout)
.split('\0')
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect())
}
fn unobtainable(e: Error) -> Error {
Error::Io(format!(
"{e}\n\nThe gate reads history: the diff is a three-dot range, and a deleted path \
is judged against the snapshot it lived in (spec 100). That history could not be \
read, so the gate has not judged this change and will not report a pass it did \
not compute.\n\
If this is a shallow clone, fetch enough history to reach the merge base \
(`git fetch --deepen=<n>`, or clone without `--depth`). If the histories are \
unrelated, name a base that shares one."
))
}
struct TempRoot(PathBuf);
impl TempRoot {
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.as_nanos())
.unwrap_or(0);
for attempt in 0..16u32 {
let root = parent.join(format!("spec-spine-couple-{pid}-{nanos}-{attempt}"));
if std::fs::create_dir(&root).is_ok() {
return Ok(TempRoot(root));
}
}
Err(Error::Io(
"could not create a temporary directory for the prior snapshot".to_string(),
))
}
}
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[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,
waiver_as_of: None,
waiver_uses: Vec::new(),
json: false,
};
let d = build_diff_input(root, &args).unwrap().diff;
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);
}
}